From 04b01789b12ba1e18c65eb9dcbcdc1c2c1d47b29 Mon Sep 17 00:00:00 2001 From: Domantas Petrauskas <5850190+ptrdom@users.noreply.github.com> Date: Sat, 3 Feb 2024 14:14:32 +0200 Subject: [PATCH] Electron plugin (#72) --- .github/workflows/test.yml | 4 +- build.sbt | 64 ++-- .../EsbuildElectronProcessConfiguration.scala | 16 + .../ScalaJSEsbuildElectronPlugin.scala | 278 ++++++++++++++++++ .../scalajsesbuild/electron/Scripts.scala | 90 ++++++ .../scalajsesbuild/electron/package.scala | 61 ++++ .../app/esbuild/electron-builder.json5 | 37 +++ .../electron-project/app/esbuild/index.html | 19 ++ .../electron-project/app/esbuild/package.json | 17 ++ .../electron-project/app/esbuild/styles.css | 7 + .../app/src/main/scala/example/Main.scala | 64 ++++ .../app/src/main/scala/example/Preload.scala | 42 +++ .../app/src/main/scala/example/Renderer.scala | 30 ++ .../example/facade/electron/Electron.scala | 47 +++ .../example/facade/node/EventEmitter.scala | 8 + .../main/scala/example/facade/node/Fs.scala | 10 + .../main/scala/example/facade/node/Node.scala | 18 ++ .../main/scala/example/facade/node/OS.scala | 10 + .../main/scala/example/facade/node/Path.scala | 10 + .../electron-project/build.sbt | 139 +++++++++ .../test/scala/example/PlaywrightSpec.scala | 57 ++++ .../example/facade/playwright/Electron.scala | 39 +++ .../src/test/scala/example/SeleniumSpec.scala | 100 +++++++ .../electron-project/project/plugins.sbt | 32 ++ .../electron-project/test | 29 ++ .../scalajsesbuild/EsbuildWebScripts.scala | 52 ++-- .../ScalaJSEsbuildWebPlugin.scala | 155 +++++----- .../scala/scalajsesbuild/EsbuildScripts.scala | 12 +- .../scalajsesbuild/ScalaJSEsbuildPlugin.scala | 18 +- .../main/scala/scalajsesbuild/package.scala | 36 +++ 30 files changed, 1362 insertions(+), 139 deletions(-) create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5 create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b172e01..8d42544f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,4 +30,6 @@ jobs: - name: Setup pnpm run: corepack prepare pnpm@8.8.0 --activate - name: Run tests - run: sbt scalafmtSbtCheck scalafmtCheckAll test scriptedSequentialPerModule + uses: coactions/setup-xvfb@v1 + with: + run: sbt scalafmtSbtCheck scalafmtCheckAll test scriptedSequentialPerModule diff --git a/build.sbt b/build.sbt index 567af9e2..094bce59 100644 --- a/build.sbt +++ b/build.sbt @@ -22,6 +22,7 @@ lazy val `scalajs-esbuild` = (project in file(".")) .settings(publish / skip := true) .aggregate( `sbt-scalajs-esbuild`, + `sbt-scalajs-esbuild-electron`, `sbt-scalajs-esbuild-web`, `sbt-web-scalajs-esbuild` ) @@ -33,6 +34,31 @@ lazy val commonSettings = Seq( scriptedBufferLog := false ) +def shadingSettings = Seq( + // workaround for https://github.com/coursier/sbt-shading/issues/39 + packagedArtifacts := { + val sbtV = (pluginCrossBuild / sbtBinaryVersion).value + val scalaV = scalaBinaryVersion.value + val packagedArtifactsV = packagedArtifacts.value + val nameV = name.value + + val (legacyArtifact, legacyFile) = packagedArtifactsV + .find { case (a, _) => + a.`type` == "jar" && a.name == nameV + } + .getOrElse(sys.error("Legacy jar not found")) + val mavenArtifact = + legacyArtifact.withName(nameV + s"_${scalaV}_$sbtV") + val mavenFile = new File( + legacyFile.getParentFile, + legacyFile.name.replace(legacyArtifact.name, mavenArtifact.name) + ) + IO.copyFile(legacyFile, mavenFile) + + packagedArtifactsV + (mavenArtifact -> mavenFile) + } +) + lazy val `sbt-scalajs-esbuild` = project .in(file("sbt-scalajs-esbuild")) @@ -65,28 +91,7 @@ lazy val `sbt-scalajs-esbuild-web` = project val () = scriptedDependencies.value val () = (`sbt-scalajs-esbuild` / publishLocal).value }, - // workaround for https://github.com/coursier/sbt-shading/issues/39 - packagedArtifacts := { - val sbtV = (pluginCrossBuild / sbtBinaryVersion).value - val scalaV = scalaBinaryVersion.value - val packagedArtifactsV = packagedArtifacts.value - val nameV = name.value - - val (legacyArtifact, legacyFile) = packagedArtifactsV - .find { case (a, _) => - a.`type` == "jar" && a.name == nameV - } - .getOrElse(sys.error("Legacy jar not found")) - val mavenArtifact = - legacyArtifact.withName(nameV + s"_${scalaV}_$sbtV") - val mavenFile = new File( - legacyFile.getParentFile, - legacyFile.name.replace(legacyArtifact.name, mavenArtifact.name) - ) - IO.copyFile(legacyFile, mavenFile) - - packagedArtifactsV + (mavenArtifact -> mavenFile) - } + shadingSettings ) .dependsOn(`sbt-scalajs-esbuild`) @@ -104,6 +109,21 @@ lazy val `sbt-web-scalajs-esbuild` = ) .dependsOn(`sbt-scalajs-esbuild-web`) +lazy val `sbt-scalajs-esbuild-electron` = + project + .in(file("sbt-scalajs-esbuild-electron")) + .enablePlugins(SbtPlugin) + .settings(commonSettings) + .settings( + scriptedDependencies := { + val () = scriptedDependencies.value + val () = (`sbt-scalajs-esbuild` / publishLocal).value + }, + shadingSettings + ) + .dependsOn(`sbt-scalajs-esbuild-web`) + +// workaround for https://github.com/sbt/sbt/issues/7431 TaskKey[Unit]("scriptedSequentialPerModule") := { Def.taskDyn { val projects: Seq[ProjectReference] = `scalajs-esbuild`.aggregate diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala new file mode 100644 index 00000000..51118aaa --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala @@ -0,0 +1,16 @@ +package scalajsesbuild + +class EsbuildElectronProcessConfiguration( + val mainModuleID: String, + val preloadModuleIDs: Set[String], + val rendererModuleIDs: Set[String] +) { + override def toString: String = { + s"EsbuildElectronProcessConfiguration($mainModuleID,$preloadModuleIDs,$rendererModuleIDs)" + } +} + +object EsbuildElectronProcessConfiguration { + def main(moduleID: String): EsbuildElectronProcessConfiguration = + new EsbuildElectronProcessConfiguration(moduleID, Set.empty, Set.empty) +} diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala new file mode 100644 index 00000000..eb8a68f9 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala @@ -0,0 +1,278 @@ +package scalajsesbuild + +import org.scalajs.linker.interface.ModuleInitializer +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.ModuleKind +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.jsEnvInput +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSLinkerConfig +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSModuleInitializers +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSStage +import org.scalajs.sbtplugin.Stage +import sbt.* +import sbt.AutoPlugin +import sbt.Keys.* +import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundle +import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundleScript +import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildInstall +import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildScalaJSModuleConfigurations +import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildBundleHtmlEntryPoints +import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildServeScript +import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildServeStart +import scalajsesbuild.electron.* + +import scala.sys.process.Process + +object ScalaJSEsbuildElectronPlugin extends AutoPlugin { + + override def requires = ScalaJSEsbuildWebPlugin + + object autoImport { + // TODO need to replace this with electron process config - not needed for bundling, but needed for serving + val esbuildElectronProcessConfiguration + : TaskKey[EsbuildElectronProcessConfiguration] = taskKey( + "Configuration linking Scala.js modules to Electron process module components" + ) + } + + import autoImport.* + + override lazy val projectSettings: Seq[Setting[?]] = + Seq( + scalaJSLinkerConfig ~= { + _.withModuleKind( + ModuleKind.ESModule + ) // TODO try using CommonJSModule - need to figure out how to disable Closure compiler + }, + esbuildElectronProcessConfiguration := { + val modules: Seq[ModuleInitializer] = scalaJSModuleInitializers.value + (modules.headOption, modules.tail.isEmpty) match { + case (Some(module), true) => + EsbuildElectronProcessConfiguration.main(module.moduleID) + case _ => + sys.error( + "Unable to automatically derive `esbuildElectronProcessConfiguration`, the settings needs to be provided manually" + ) + } + } + ) ++ inConfig(Compile)(perConfigSettings) ++ + inConfig(Test)(perConfigSettings) + + private lazy val perConfigSettings: Seq[Setting[?]] = Seq( + esbuildScalaJSModuleConfigurations := Map.empty, + jsEnvInput := jsEnvInputTask.value, + run := Def.taskDyn { + val stageTask = scalaJSStage.value.stageTask + Def.task { + (stageTask / esbuildBundle).value + + val stageTaskReport = stageTask.value.data + val mainModule = resolveMainModule(stageTaskReport) + + val targetDirectory = (esbuildInstall / crossTarget).value + val outputDirectory = + (stageTask / esbuildBundle / crossTarget).value + val path = + targetDirectory + .relativize(new File(outputDirectory, mainModule.jsFileName)) + .getOrElse( + sys.error( + s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]" + ) + ) + + val exitValue = Process( + "node" :: "./node_modules/electron/cli" :: path.toString :: Nil, + targetDirectory + ).run(streams.value.log) + .exitValue() + if (exitValue != 0) { + sys.error(s"Nonzero exit value: $exitValue") + } + } + }.value + ) ++ + perScalaJSStageSettings(Stage.FastOpt) ++ + perScalaJSStageSettings(Stage.FullOpt) + + private def perScalaJSStageSettings(stage: Stage): Seq[Setting[?]] = { + val stageTask = stage.stageTask + + Seq( + stageTask / esbuildBundleScript := { + val configurationV = configuration.value + val stageTaskReport = stageTask.value.data + val electronProcessConfiguration = + esbuildElectronProcessConfiguration.value + val ( + mainModuleEntryPoint, + preloadModuleEntryPoints, + rendererModuleEntryPoints + ) = extractEntryPointsByProcess( + stageTaskReport, + electronProcessConfiguration + ) + val nodeEntryPointsJsArray = + (preloadModuleEntryPoints + mainModuleEntryPoint) + .map("'" + _ + "'") + .mkString("[", ",", "]") + val rendererModuleEntryPointsJsArray = rendererModuleEntryPoints + .map("'" + _ + "'") + .mkString("[", ",", "]") + val targetDirectory = (esbuildInstall / crossTarget).value + val outputDirectory = + (stageTask / esbuildBundle / crossTarget).value + val relativeOutputDirectory = + targetDirectory + .relativize(outputDirectory) + .getOrElse( + sys.error( + s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]" + ) + ) + val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'" + val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value + require( + !htmlEntryPoints.forall(_.isAbsolute), + "HTML entry point paths must be relative" + ) + val htmlEntryPointsJsArray = + htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]") + + val minify = if (configurationV == Test) { + false + } else { + true + } + + // language=JS + s""" + |${EsbuildScripts.esbuildOptions} + | + |${EsbuildScripts.bundle} + | + |${EsbuildWebScripts.htmlTransform} + | + |${EsbuildWebScripts.transformHtmlEntryPoints} + | + |bundle( + | ${EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Node.jsValue}, + | $nodeEntryPointsJsArray, + | $relativeOutputDirectoryJs, + | null, + | false, + | $minify, + | 'sbt-scalajs-esbuild-node-bundle-meta.json', + | {external: ['electron']} + |); + | + |const metaFilePromise = bundle( + | ${EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser.jsValue}, + | $rendererModuleEntryPointsJsArray, + | $relativeOutputDirectoryJs, + | 'assets', + | false, + | $minify, + | 'sbt-scalajs-esbuild-renderer-bundle-meta.json' + |); + | + |metaFilePromise + | .then((metaFile) => { + | transformHtmlEntryPoints( + | $htmlEntryPointsJsArray, + | $relativeOutputDirectoryJs, + | metaFile + | ); + | }); + |""".stripMargin + }, + stageTask / esbuildServeScript := { + val configurationV = configuration.value + val stageTaskReport = stageTask.value.data + val electronProcessConfiguration = + esbuildElectronProcessConfiguration.value + val ( + mainModuleEntryPoint, + preloadModuleEntryPoints, + rendererModuleEntryPoints + ) = extractEntryPointsByProcess( + stageTaskReport, + electronProcessConfiguration + ) + val mainModuleEntryPointJs = s"'$mainModuleEntryPoint'" + val preloadModuleEntryPointsJs = + preloadModuleEntryPoints + .map("'" + _ + "'") + .mkString("[", ",", "]") + val rendererModuleEntryPointsJsArray = rendererModuleEntryPoints + .map("'" + _ + "'") + .mkString("[", ",", "]") + val targetDirectory = (esbuildInstall / crossTarget).value + val nodeRelativeOutputDirectoryJs = { + val outputDirectory = + (stageTask / esbuildBundle / crossTarget).value + val nodeRelativeOutputDirectory = targetDirectory + .relativize(outputDirectory) + .getOrElse( + sys.error( + s"Target directory [$targetDirectory] must be parent directory of node output directory [$outputDirectory]" + ) + ) + s"'$nodeRelativeOutputDirectory'" + } + val rendererRelativeOutputDirectoryJs = { + val outputDirectory = + (stageTask / esbuildServeStart / crossTarget).value + val relativeOutputDirectory = + targetDirectory + .relativize(outputDirectory) + .getOrElse( + sys.error( + s"Target directory [$targetDirectory] must be parent directory of renderer output directory [$outputDirectory]" + ) + ) + s"'$relativeOutputDirectory'" + } + val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value + require( + !htmlEntryPoints.forall(_.isAbsolute), + "HTML entry point paths must be relative" + ) + val htmlEntryPointsJsArray = + htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]") + + // language=JS + s""" + |${EsbuildScripts.esbuildOptions} + | + |${EsbuildScripts.bundle} + | + |${EsbuildWebScripts.htmlTransform} + | + |${EsbuildWebScripts.esbuildLiveReload} + | + |${EsbuildWebScripts.serve} + | + |${electron.Scripts.electronServe} + | + |serve( + | $rendererModuleEntryPointsJsArray, + | $rendererRelativeOutputDirectoryJs, + | 'assets', + | 'sbt-scalajs-esbuild-renderer-bundle-meta.json', + | 8001, + | 8000, + | $htmlEntryPointsJsArray + |) + | .then((reloadEventEmitter) => { + | electronServe( + | reloadEventEmitter, + | 8000, + | $mainModuleEntryPointJs, + | $preloadModuleEntryPointsJs, + | $nodeRelativeOutputDirectoryJs + | ); + | }); + |""".stripMargin + } + ) + } +} diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala new file mode 100644 index 00000000..506d4573 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala @@ -0,0 +1,90 @@ +package scalajsesbuild.electron + +object Scripts { + + private[scalajsesbuild] def electronServe = { + // language=JS + """const electronServe = async ( + | reloadEventEmitter, + | rendererBuildServerProxyPort, + | mainEntryPoint, + | preloadEntryPoints, + | electronBuildOutputDirectory + |) => { + | const path = require('path'); + | const esbuild = require('esbuild'); + | const { spawn } = require('node:child_process'); + | const electron = require('electron'); + | + | await (async function () { + | const plugins = [{ + | name: 'renderer-reload-plugin', + | setup(build) { + | build.onEnd(() => { + | reloadEventEmitter.emit('reload'); + | }); + | }, + | }]; + | + | const ctx = await esbuild.context({ + | entryPoints: preloadEntryPoints, + | bundle: true, + | outdir: electronBuildOutputDirectory, + | logOverride: { + | 'equals-negative-zero': 'silent', + | }, + | logLevel: "info", + | entryNames: '[name]', + | assetNames: '[name]', + | plugins: plugins, + | platform: 'node', + | external: ['electron'], + | }); + | + | ctx.watch(); + | })(); + | + | await (async function () { + | const plugins = [{ + | name: 'main-reload-plugin', + | setup(build) { + | let electronProcess = null; + | build.onEnd(() => { + | if (electronProcess != null) { + | electronProcess.handle.removeListener('exit', electronProcess.closeListener); + | electronProcess.handle.kill(); + | electronProcess = null; + | } + | electronProcess = { + | handle: spawn(electron, [path.join(electronBuildOutputDirectory, mainEntryPoint), '.'], { stdio: 'inherit' }), + | closeListener: () => process.exit() + | }; + | electronProcess.handle.on('exit', electronProcess.closeListener); + | }); + | }, + | }]; + | + | const ctx = await esbuild.context({ + | entryPoints: [mainEntryPoint], + | bundle: true, + | outdir: electronBuildOutputDirectory, + | logOverride: { + | 'equals-negative-zero': 'silent', + | }, + | logLevel: "info", + | entryNames: '[name]', + | assetNames: '[name]', + | plugins: plugins, + | platform: 'node', + | external: ['electron'], + | }); + | + | ctx.watch(); + | })(); + | + | Object.assign(process.env, { + | DEV_SERVER_URL: `http://localhost:${rendererBuildServerProxyPort}`, + | }) + |};""".stripMargin + } +} diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala new file mode 100644 index 00000000..68b2793f --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala @@ -0,0 +1,61 @@ +package scalajsesbuild + +import org.scalajs.linker.interface.Report +import org.scalajs.linker.interface.unstable + +package object electron { + + private[scalajsesbuild] def extractEntryPointsByProcess( + report: Report, + electronProcessConfiguration: EsbuildElectronProcessConfiguration + ) = { + report match { + case report: unstable.ReportImpl => + val mainModuleEntryPoint = report.publicModules + .find( + _.moduleID == electronProcessConfiguration.mainModuleID + ) + .getOrElse( + sys.error( + s"Main module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules" + ) + ) + .jsFileName + val preloadModuleEntryPoints = + electronProcessConfiguration.preloadModuleIDs + .map(moduleID => + report.publicModules + .find( + _.moduleID == moduleID + ) + .getOrElse( + sys.error( + s"Preload module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules" + ) + ) + .jsFileName + ) + val rendererModuleEntryPoints = + electronProcessConfiguration.rendererModuleIDs + .map(moduleID => + report.publicModules + .find( + _.moduleID == moduleID + ) + .getOrElse( + sys.error( + s"Renderer module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules" + ) + ) + .jsFileName + ) + ( + mainModuleEntryPoint, + preloadModuleEntryPoints, + rendererModuleEntryPoints + ) + case unhandled => + sys.error(s"Unhandled report type [$unhandled]") + } + } +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5 b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5 new file mode 100644 index 00000000..ad955755 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5 @@ -0,0 +1,37 @@ +/** + * @see https://www.electron.build/configuration/configuration + */ +{ + "appId": "YourAppID", + "asar": true, + "directories": { + "output": "release/${version}" + }, + "files": [ + "out" + ], + "mac": { + "artifactName": "${productName}_${version}.${ext}", + "target": [ + "dmg" + ] + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "artifactName": "${productName}_${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + publish: null +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html new file mode 100644 index 00000000..65a3ea28 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html @@ -0,0 +1,19 @@ + + + + + + Hello World! + + +

Hello World!

+ We are using Node.js , + Chromium , + and Electron . + + + + +

+ + diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json new file mode 100644 index 00000000..e0495048 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json @@ -0,0 +1,17 @@ +{ + "name": "electron-project", + "private": true, + "version": "0.0.0", + "main": "out/main.js", + "repository": { + "url": "git+https://github.com/ptrdom/scalajs-esbuild.git" + }, + "devDependencies": { + "esbuild": "0.19.11", + "electron-builder": "24.9.1", + "electron-chromedriver": "28.1.0", + "jsdom": "22.1.0", + "electron": "28.1.0", + "playwright": "1.41.1" + } +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css new file mode 100644 index 00000000..fa8b48ef --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css @@ -0,0 +1,7 @@ +/* styles.css */ + +/* Add styles here to customize the appearance of your app */ + +#css-hook::after { + content: 'CSS WORKS!' +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala new file mode 100644 index 00000000..9965a89a --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala @@ -0,0 +1,64 @@ +package example + +import example.facade.electron.BrowserWindow +import example.facade.electron.BrowserWindowConfig +import example.facade.electron.ElectronGlobals.app +import example.facade.electron.WebPreferences +import example.facade.node.NodeGlobals.__dirname +import example.facade.node.NodeGlobals.process +import example.facade.node.Path.join + +import scala.scalajs.js +import scala.scalajs.js.| + +object Main extends App { + // Create the browser window. + def createWindow(): Unit = { + val mainWindow = new BrowserWindow(new BrowserWindowConfig { + override val height = 600 + override val width = 800 + override val webPreferences = new WebPreferences { + override val preload = join(__dirname, "preload.js") + } + }) + + // and load the index.html of the app. + process.env.DEV_SERVER_URL + .asInstanceOf[js.UndefOr[String]] + .toOption + .fold( + mainWindow.loadFile(join(__dirname, "../out", "index.html")) + )(url => mainWindow.loadURL(url)) + + // Open the DevTools. + // mainWindow.webContents.openDevTools() + } + + // This method will be called when Electron has finished + // initialization and is ready to create browser windows. + // Some APIs can only be used after this event occurs. + app + .whenReady() + .`then`((_ => { + createWindow() + + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + app.on( + "activate", + () => { + if (BrowserWindow.getAllWindows().length == 0) createWindow() + } + ) + }): js.Function1[Unit, Unit | js.Thenable[Unit]]) + + // Quit when all windows are closed, except on macOS. There, it's common + // for applications and their menu bar to stay active until the user quits + // explicitly with Cmd + Q. + app.on( + "window-all-close", + () => { + if (process.platform != "darwin") app.quit() + } + ) +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala new file mode 100644 index 00000000..464ad7b1 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala @@ -0,0 +1,42 @@ +package example + +import example.facade.node.NodeGlobals.process +import org.scalajs.dom +import org.scalajs.dom.document +import org.scalajs.dom.window + +import scala.scalajs.js + +/** The preload script runs before. It has access to web APIs as well as + * Electron's renderer process modules and some polyfilled Node.js functions. + * + * https://www.electronjs.org/docs/latest/tutorial/sandbox + */ +object Preload extends App { + window.addEventListener( + "DOMContentLoaded", + { (_: dom.Event) => + val replaceText = (selector: String, text: String) => { + val element = document.getElementById(selector) + if (element != null) element.innerText = text + } + + js.Array("chrome", "node", "electron") + .foreach(`type` => + replaceText( + s"${`type`}-version", + process.versions.get(`type`).orNull + ) + ) + } + ) + + document.addEventListener( + "DOMContentLoaded", + { (_: dom.Event) => + val h1 = document.createElement("h1") + h1.textContent = "PRELOAD WORKS!" + document.body.append(h1) + } + ) +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala new file mode 100644 index 00000000..4af6e836 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala @@ -0,0 +1,30 @@ +package example + +import org.scalajs.dom +import org.scalajs.dom.document + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +@js.native +@JSImport("./styles.css", JSImport.Namespace) +object Style extends js.Object + +/** This file is loaded via the <script> tag in the index.html file and + * will be executed in the renderer process for that window. No Node.js APIs + * are available in this process because `nodeIntegration` is turned off and + * `contextIsolation` is turned on. Use the contextBridge API in `preload.js` + * to expose Node.js functionality from the main process. + */ +object Renderer extends App { + val style = Style + + document.addEventListener( + "DOMContentLoaded", + { (_: dom.Event) => + val h1 = document.createElement("h1") + h1.textContent = "RENDERER WORKS!" + document.body.append(h1) + } + ) +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala new file mode 100644 index 00000000..c021ef5c --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala @@ -0,0 +1,47 @@ +package example.facade.electron + +import example.facade.node.EventEmitter + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +object ElectronGlobals { + @js.native + @JSImport("electron", "app") + def app: Application = js.native +} + +@js.native +trait Application extends EventEmitter { + def whenReady(): js.Promise[Unit] + def quit(): Unit +} + +@js.native +@JSImport("electron", "BrowserWindow") +class BrowserWindow(config: BrowserWindowConfig) extends js.Object { + def loadURL(url: String): js.Promise[Unit] = js.native + def loadFile(filePath: String): js.Promise[Unit] = js.native + def webContents: WebContents = js.native +} + +trait BrowserWindowConfig extends js.Object { + val height: js.UndefOr[Int] = js.undefined + val width: js.UndefOr[Int] = js.undefined + val webPreferences: js.UndefOr[WebPreferences] = js.undefined +} + +trait WebPreferences extends js.Object { + val preload: js.UndefOr[String] = js.undefined + val nodeIntegration: js.UndefOr[Boolean] = js.undefined +} + +trait WebContents extends js.Object { + def openDevTools(): Unit +} + +@js.native +@JSImport("electron", "BrowserWindow") +object BrowserWindow extends js.Object { + def getAllWindows(): js.Array[BrowserWindow] = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala new file mode 100644 index 00000000..64ecefca --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala @@ -0,0 +1,8 @@ +package example.facade.node + +import scala.scalajs.js + +@js.native +trait EventEmitter extends js.Object { + def on(event: String, listener: js.Function): Unit = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala new file mode 100644 index 00000000..5b8ff012 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala @@ -0,0 +1,10 @@ +package example.facade.node + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +object Fs { + @js.native + @JSImport("fs", "mkdtempSync") + def mkdtempSync(prefix: String): String = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala new file mode 100644 index 00000000..fea91706 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala @@ -0,0 +1,18 @@ +package example.facade.node + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobalScope + +@js.native +@JSGlobalScope +object NodeGlobals extends js.Object { + val process: Process = js.native + val __dirname: String = js.native +} + +@js.native +trait Process extends js.Object { + val platform: String = js.native + val versions: js.Dictionary[String] = js.native + val env: js.Dynamic = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala new file mode 100644 index 00000000..6932fa6e --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala @@ -0,0 +1,10 @@ +package example.facade.node + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +object OS { + @js.native + @JSImport("os", "tmpdir") + def tmpdir(): String = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala new file mode 100644 index 00000000..43a61f44 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala @@ -0,0 +1,10 @@ +package example.facade.node + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +object Path { + @js.native + @JSImport("path", "join") + def join(paths: String*): String = js.native +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt new file mode 100644 index 00000000..d7c34b2b --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt @@ -0,0 +1,139 @@ +import org.scalajs.jsenv.nodejs.NodeJSEnv +import org.scalajs.linker.interface.ModuleInitializer +import org.scalajs.sbtplugin.Stage +import scalajsesbuild.EsbuildElectronProcessConfiguration +import scala.sys.process._ + +lazy val root = (project in file(".")) + .aggregate( + app, + `integration-test-playwright-node`, + `integration-test-selenium-jvm` + ) + +ThisBuild / scalaVersion := "2.13.8" + +val viteElectronBuildPackage = + taskKey[Unit]("Generate package directory with electron-builder") +val viteElectronBuildDistributable = + taskKey[Unit]("Package in distributable format with electron-builder") + +lazy val app = (project in file("app")) + .enablePlugins(ScalaJSEsbuildElectronPlugin) + .settings( + Test / test := {}, + // Suppress meaningless 'multiple main classes detected' warning + Compile / mainClass := None, + scalaJSModuleInitializers := Seq( + ModuleInitializer + .mainMethodWithArgs("example.Main", "main") + .withModuleID("main"), + ModuleInitializer + .mainMethodWithArgs("example.Preload", "main") + .withModuleID("preload"), + ModuleInitializer + .mainMethodWithArgs("example.Renderer", "main") + .withModuleID("renderer") + ), + Compile / esbuildElectronProcessConfiguration := new EsbuildElectronProcessConfiguration( + "main", + Set("preload"), + Set("renderer") + ), + libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.2.0", + Seq(Compile, Test) + .flatMap { config => + inConfig(config)( + Seq(Stage.FastOpt, Stage.FullOpt).flatMap { stage => + val stageTask = stage match { + case Stage.FastOpt => fastLinkJS + case Stage.FullOpt => fullLinkJS + } + + def fn(args: List[String] = Nil) = Def.task { + val log = streams.value.log + + (stageTask / esbuildBundle).value + + val targetDir = (esbuildInstall / crossTarget).value + + val exitValue = Process( + "node" :: "node_modules/electron-builder/cli" :: Nil ::: args, + targetDir + ).run(log).exitValue() + if (exitValue != 0) { + sys.error(s"Nonzero exit value: $exitValue") + } else () + } + + Seq( + stageTask / viteElectronBuildPackage := fn("--dir" :: Nil).value, + stageTask / viteElectronBuildDistributable := fn().value + ) + } + ) + } + ) + +lazy val `integration-test-selenium-jvm` = + (project in file("integration-test-selenium-jvm")) + .settings( + Test / test := (Test / test).dependsOn { + Def.taskDyn { + val stageTask = (app / Compile / scalaJSStage).value match { + case Stage.FastOpt => fastLinkJS + case Stage.FullOpt => fullLinkJS + } + Def.task { + (((app / Compile) / stageTask) / esbuildBundle).value + } + } + }.value, + libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % "test", + libraryDependencies ++= Seq( + "org.scalatestplus" %% "selenium-4-7" % "3.2.15.0" % "test", + "org.seleniumhq.selenium" % "selenium-java" % "4.16.1" % "test" + ) // should be upgraded when Electron upgrades its chromium version + ) + +lazy val `integration-test-playwright-node` = + (project in file("integration-test-playwright-node")) + .enablePlugins(ScalaJSPlugin) + .settings( + scalaJSLinkerConfig ~= { + _.withModuleKind(ModuleKind.CommonJSModule) + }, + Test / jsEnv := Def.taskDyn { + val stageTask = (app / Compile / scalaJSStage).value match { + case Stage.FastOpt => fastLinkJS + case Stage.FullOpt => fullLinkJS + } + + Def.task { + (((app / Compile) / stageTask) / esbuildBundle).value + + val sourcesDirectory = + (((app / Compile) / esbuildInstall) / crossTarget).value + val outputDirectory = + ((((app / Compile) / stageTask) / esbuildBundle) / crossTarget).value + val mainModuleID = + ((app / Compile) / esbuildElectronProcessConfiguration).value.mainModuleID + + val nodePath = (sourcesDirectory / "node_modules").absolutePath + val mainPath = (outputDirectory / s"$mainModuleID.js").absolutePath + + new NodeJSEnv( + NodeJSEnv + .Config() + .withEnv( + Map( + "NODE_PATH" -> nodePath, + "MAIN_PATH" -> mainPath + ) + ) + ) + } + }.value, + libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % "test" + ) + .dependsOn(app) diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala new file mode 100644 index 00000000..245e03ed --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala @@ -0,0 +1,57 @@ +package example + +import example.facade.node.Fs.mkdtempSync +import example.facade.node.OS.tmpdir +import example.facade.node.Path.join +import example.facade.node._ +import example.facade.playwright._ +import org.scalatest.Assertion +import org.scalatest.freespec.AsyncFreeSpec +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.Future +import scala.scalajs.concurrent.JSExecutionContext +import scala.scalajs.js +import scala.scalajs.js.Thenable.Implicits._ + +class PlaywrightSpec extends AsyncFreeSpec with Matchers { + + implicit override def executionContext = + JSExecutionContext.queue + + "Work" in { + Electron + .launch(new LaunchConfig { + override val args = + js.Array( + NodeGlobals.process.env.MAIN_PATH.asInstanceOf[String], + s"--user-data-dir=${mkdtempSync(join(tmpdir(), "electron-app-user-data-directory-"))}" + ) + }) + .flatMap(electronApp => + { + for { + // https://playwright.dev/docs/api/class-electron\ + // TODO figure out how to `electronApp.evaluate` + window <- electronApp.firstWindow() + title <- window.title() + _ = println(title) + _ <- window.screenshot(new ScreenshotConfig { + override val path = "intro.png" + }) + _ <- window.isVisible("text='PRELOAD WORKS!'").map(_ shouldBe true) + _ <- window.isVisible("text='RENDERER WORKS!'").map(_ shouldBe true) + - <- window + .evaluate[String]( + "window.getComputedStyle(document.getElementById('css-hook'), '::after')['content']" + ) + .map(_ shouldBe "\"CSS WORKS!\"") + } yield succeed + } + .transform { result => + electronApp.close() + result + } + ) + } +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala new file mode 100644 index 00000000..76b51bd7 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala @@ -0,0 +1,39 @@ +package example.facade.playwright + +import example.facade.electron + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +@js.native +@JSImport("playwright", "_electron") +object Electron extends js.Object { + def launch(config: LaunchConfig): js.Promise[ElectronApplication] = js.native +} + +trait LaunchConfig extends js.Object { + val args: js.Array[String] +} + +@js.native +trait ElectronApplication extends js.Object { + + def firstWindow(): js.Promise[Window] = js.native + + def close(): js.Promise[Unit] = js.native +} + +@js.native +trait Window extends js.Object { + def title(): js.Promise[String] = js.native + + def screenshot(config: ScreenshotConfig): js.Promise[js.Dynamic] + + def isVisible(selector: String): js.Promise[Boolean] + + def evaluate[T](pageFunction: String): js.Promise[T] +} + +trait ScreenshotConfig extends js.Object { + val path: String +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala new file mode 100644 index 00000000..130ad0a3 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala @@ -0,0 +1,100 @@ +package example + +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths + +import org.openqa.selenium.WebDriver +import org.openqa.selenium.chrome.ChromeDriver +import org.openqa.selenium.chrome.ChromeOptions +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.selenium.WebBrowser + +import scala.annotation.tailrec +import scala.sys.process._ + +class SeleniumSpec extends AnyFreeSpec with Matchers { + + val targetDirectory = { + // test can be executed by IntelliJ Run/Debug configurations and from sbt, + // working directory is not the same for both of them, + // so we check both of them if either exists + val scalaTestRunnerPath = "../" + val sbtRunnerPath = "" + val targetRelativePath = "/app/target/scala-2.13/esbuild/main" + + @tailrec + def resolve(toTry: List[String]): File = { + toTry match { + case ::(head, next) => + val maybeTargetDirectory = Paths + .get( + new File(head).getAbsolutePath, + targetRelativePath + ) + .toFile + if (new File(maybeTargetDirectory, "package.json").exists()) { + maybeTargetDirectory + } else { + resolve(next) + } + case Nil => + sys.error("Target directory for esbuild project was not resolved") + } + } + resolve(List(scalaTestRunnerPath, sbtRunnerPath)) + } + val debugPort = 9222 + + System.setProperty( + "webdriver.chrome.driver", + Paths + .get( + targetDirectory.getAbsolutePath, { + val extension = + if (System.getProperty("os.name").toLowerCase.contains("win")) + ".exe" + else "" + s"node_modules/electron-chromedriver/bin/chromedriver$extension" + } + ) + .normalize() + .toString + ) + + "Work" in { + val userDataDir = + Files.createTempDirectory("electron-app-user-data-directory") + val process = Process( + "node" :: + "./node_modules/electron/cli" :: + "./out/main.js" :: + s"--remote-debugging-port=$debugPort" :: + "--remote-allow-origins=*" :: + s"--user-data-dir=${userDataDir.toAbsolutePath}" :: + Nil, + targetDirectory + ).run + try { + val options = new ChromeOptions() + options.addArguments("--remote-allow-origins=*") + options.setExperimentalOption("debuggerAddress", s"localhost:$debugPort") + implicit val webDriver: WebDriver = new ChromeDriver(options) + try { + new WebBrowser { + pageTitle shouldBe "Hello World!" + find(xpath("//h1[text()='PRELOAD WORKS!']")) shouldBe defined + find(xpath("//h1[text()='RENDERER WORKS!']")) shouldBe defined + executeScript( + "return window.getComputedStyle(document.getElementById('css-hook'), '::after')['content']" + ).asInstanceOf[String] shouldBe "\"CSS WORKS!\"" + } + } finally { + webDriver.quit() + } + } finally { + process.destroy() + } + } +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt new file mode 100644 index 00000000..cb2dcf72 --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt @@ -0,0 +1,32 @@ +val sourcePlugins = sys.props + .get("plugin.version") + .map { version => + println(s"Using plugin(s) version [$version]") + Seq.empty + } + .getOrElse { + println("Building plugin(s) from source") + Seq( + ProjectRef( + file("../../../../../../"), + "sbt-scalajs-esbuild-electron" + ): ClasspathDep[ProjectReference] + ) + } + +lazy val root = (project in file(".")) + .dependsOn(sourcePlugins: _*) + +if (sourcePlugins.nonEmpty) { + Seq.empty +} else { + val scalaJSEsbuildVersion = sys.props.getOrElse( + "plugin.version", + sys.error("'plugin.version' environment variable is not set") + ) + Seq( + addSbtPlugin( + "me.ptrdom" % "sbt-scalajs-esbuild-electron" % scalaJSEsbuildVersion + ) + ) +} diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test new file mode 100644 index 00000000..d249ef8a --- /dev/null +++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test @@ -0,0 +1,29 @@ +$ absent app/esbuild/package-lock.json +> clean +> set app/Compile/scalaJSStage := org.scalajs.sbtplugin.Stage.FastOpt +> test +> app/fullLinkJS/esbuildServeStart +# TODO figure out how to test if dev mode works +> app/fullLinkJS/esbuildServeStop +$ exists app/esbuild/package-lock.json + +$ delete app/esbuild/package-lock.json +> clean +> set app/Compile/scalaJSStage := org.scalajs.sbtplugin.Stage.FullOpt +> test +> app/fullLinkJS/esbuildServeStart +# TODO figure out how to test if dev mode works +> app/fullLinkJS/esbuildServeStop +$ exists app/esbuild/package-lock.json + +$ delete app/esbuild/package-lock.json +> clean +> app/fastLinkJS/viteElectronBuildPackage +> app/fastLinkJS/viteElectronBuildDistributable +$ exists app/esbuild/package-lock.json + +$ delete app/esbuild/package-lock.json +> clean +> app/fullLinkJS/viteElectronBuildPackage +> app/fullLinkJS/viteElectronBuildDistributable +$ exists app/esbuild/package-lock.json \ No newline at end of file diff --git a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala index fbaba551..c7120bf7 100644 --- a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala +++ b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala @@ -67,6 +67,7 @@ object EsbuildWebScripts { |""".stripMargin } + // TODO make dev server return this as a script to fix CSP issue in Electron plugin private[scalajsesbuild] def esbuildLiveReload = { // language=JS """const esbuildLiveReload = ( @@ -75,28 +76,7 @@ object EsbuildWebScripts { | return htmlString | .toString() | .replace("", ` - | + | | | `); |} @@ -205,6 +185,34 @@ object EsbuildWebScripts { | res.writeHead(404); | res.end('HTML file ['+htmlFilePath+'] not found'); | } + | } else if (path === '/@dev-server/live-reload'){ + | res.writeHead(200); + | res.end(` + | // Based on https://esbuild.github.io/api/#live-reload + | const eventSource = new EventSource('/esbuild') + | eventSource.addEventListener('change', e => { + | const { added, removed, updated } = JSON.parse(e.data) + | + | if (!added.length && !removed.length && updated.length === 1) { + | for (const link of document.getElementsByTagName('link')) { + | const url = new URL(link.href) + | + | if (url.host === location.host && url.pathname === updated[0]) { + | const next = link.cloneNode() + | next.href = updated[0] + '?' + Math.random().toString(36).slice(2) + | next.onload = () => link.remove() + | link.parentNode.insertBefore(next, link.nextSibling) + | return + | } + | } + | } + | + | location.reload() + | }); + | eventSource.addEventListener('reload', () => { + | location.reload(); + | }); + | `); | } else { | const proxyReq = http.request(options, (proxyRes) => { | if (proxyRes.statusCode === 404 && !multipleEntryPointsFound) { diff --git a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala index 10de79d3..566f5492 100644 --- a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala +++ b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala @@ -64,29 +64,32 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin { IO.read(targetDir / "sbt-scalajs-esbuild-bundle-meta.json") ) - jsFileNames(stageTask.value.data) - .map { jsFileName => - metaJson - .get("outputs") - .asInstanceOf[JObject] - .vs - .collectFirst { - case (outputBundle, output) - if output - .asInstanceOf[JObject] - .get("entryPoint") - .getString - .contains(jsFileName) => - outputBundle - } - .getOrElse( - sys.error(s"Output bundle not found for module [$jsFileName]") - ) + val mainModule = resolveMainModule(stageTask.value.data) + + val outputBundle = metaJson + .get("outputs") + .asInstanceOf[JObject] + .vs + .collectFirst { + case (outputBundle, output) + if output + .asInstanceOf[JObject] + .get("entryPoint") + .getString + .contains(mainModule.jsFileName) => + outputBundle } - .map((stageTask / esbuildInstall / crossTarget).value / _) - .map(_.toPath) - .map(Script) - .toSeq + .getOrElse( + sys.error( + s"Output bundle not found for main module [${mainModule.moduleID}]" + ) + ) + + Seq( + Script( + ((stageTask / esbuildInstall / crossTarget).value / outputBundle).toPath + ) + ) } }.value ) ++ @@ -163,6 +166,60 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin { | ); | }); |""".stripMargin + }, + stageTask / esbuildServeScript := { + val stageTaskReport = stageTask.value.data + val moduleConfigurations = esbuildScalaJSModuleConfigurations.value + val entryPoints = + extractEntryPointsByPlatform(stageTaskReport, moduleConfigurations) + .getOrElse( + EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser, + Set.empty + ) + val entryPointsJsArray = + entryPoints.map("'" + _ + "'").mkString("[", ",", "]") + val targetDirectory = (esbuildInstall / crossTarget).value + val outputDirectory = + (stageTask / esbuildServeStart / crossTarget).value + val relativeOutputDirectory = + targetDirectory + .relativize(outputDirectory) + .getOrElse( + sys.error( + s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]" + ) + ) + val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'" + val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value + require( + !htmlEntryPoints.forall(_.isAbsolute), + "HTML entry point paths must be relative" + ) + val htmlEntryPointsJsArray = + htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]") + + // language=JS + s""" + |${EsbuildScripts.esbuildOptions} + | + |${EsbuildScripts.bundle} + | + |${EsbuildWebScripts.htmlTransform} + | + |${EsbuildWebScripts.esbuildLiveReload} + | + |${EsbuildWebScripts.serve} + | + |serve( + | $entryPointsJsArray, + | $relativeOutputDirectoryJs, + | 'assets', + | 'sbt-scalajs-esbuild-serve-meta.json', + | 8001, + | 8000, + | $htmlEntryPointsJsArray + |); + |""".stripMargin } ) ++ { var process: Option[Process] = None @@ -176,60 +233,6 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin { } Seq( - stageTask / esbuildServeScript := { - val stageTaskReport = stageTask.value.data - val moduleConfigurations = esbuildScalaJSModuleConfigurations.value - val entryPoints = - extractEntryPointsByPlatform(stageTaskReport, moduleConfigurations) - .getOrElse( - EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser, - Set.empty - ) - val entryPointsJsArray = - entryPoints.map("'" + _ + "'").mkString("[", ",", "]") - val targetDirectory = (esbuildInstall / crossTarget).value - val outputDirectory = - (stageTask / esbuildServeStart / crossTarget).value - val relativeOutputDirectory = - targetDirectory - .relativize(outputDirectory) - .getOrElse( - sys.error( - s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]" - ) - ) - val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'" - val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value - require( - !htmlEntryPoints.forall(_.isAbsolute), - "HTML entry point paths must be relative" - ) - val htmlEntryPointsJsArray = - htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]") - - // language=JS - s""" - |${EsbuildScripts.esbuildOptions} - | - |${EsbuildScripts.bundle} - | - |${EsbuildWebScripts.htmlTransform} - | - |${EsbuildWebScripts.esbuildLiveReload} - | - |${EsbuildWebScripts.serve} - | - |serve( - | $entryPointsJsArray, - | $relativeOutputDirectoryJs, - | 'assets', - | 'sbt-scalajs-esbuild-serve-meta.json', - | 8001, - | 8000, - | $htmlEntryPointsJsArray - |); - |""".stripMargin - }, stageTask / esbuildServeStart / crossTarget := (esbuildInstall / crossTarget).value / "www", stageTask / esbuildServeStart := { val logger = state.value.globalLogging.full diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala index 45170eec..18611210 100644 --- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala +++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala @@ -10,7 +10,8 @@ object EsbuildScripts { | outDirectory, | outputFilesDirectory, | hashOutputFiles, - | minify + | minify, + | additionalOptions |) => { | const path = require('path'); | @@ -86,7 +87,8 @@ object EsbuildScripts { | ), | loader: knownAssetTypes.reduce((a, b) => ({...a, [`.${b}`]: `file`}), {}), | ...minifyOption, - | ...publicPathOption + | ...publicPathOption, + | ...(additionalOptions ? additionalOptions : {}) | }; |} |""".stripMargin @@ -101,7 +103,8 @@ object EsbuildScripts { | outputFilesDirectory, | hashOutputFiles, | minify, - | metaFileName + | metaFileName, + | additionalEsbuildOptions |) => { | const esbuild = require('esbuild'); | const fs = require('fs'); @@ -113,7 +116,8 @@ object EsbuildScripts { | outDirectory, | outputFilesDirectory, | hashOutputFiles, - | minify + | minify, + | additionalEsbuildOptions | ) | ); | diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala index da9e8b5b..8a9cc446 100644 --- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala +++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala @@ -86,7 +86,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin { private lazy val perConfigSettings: Seq[Setting[?]] = Seq( esbuildScalaJSModuleConfigurations := { val moduleKind = scalaJSLinkerConfig.value.moduleKind - val esbuildModuleConfiguration = new EsbuildScalaJSModuleConfiguration( + val scalaJSModuleConfiguration = new EsbuildScalaJSModuleConfiguration( platform = moduleKind match { case ModuleKind.CommonJSModule => EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Node @@ -96,7 +96,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin { ) val modules = scalaJSModuleInitializers.value modules - .map(module => module.moduleID -> esbuildModuleConfiguration) + .map(module => module.moduleID -> scalaJSModuleConfiguration) .toMap }, esbuildInstall / crossTarget := { @@ -153,18 +153,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin { changeStatus }, - jsEnvInput := Def.taskDyn { - val stageTask = scalaJSStage.value.stageTask - Def.task { - (stageTask / esbuildBundle).value - - jsFileNames(stageTask.value.data) - .map((stageTask / esbuildBundle / crossTarget).value / _) - .map(_.toPath) - .map(Script) - .toSeq - } - }.value, + jsEnvInput := jsEnvInputTask.value, esbuildFastLinkJSWrapper := { fastLinkJS.value FileTreeView.default @@ -207,6 +196,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin { installFileChanges ++ stageTaskFileChanges }, + // TODO move out of `stageTask` scope stageTask / esbuildBundle / crossTarget := (esbuildInstall / crossTarget).value / "out", stageTask / esbuildBundleScript := { val stageTaskReport = stageTask.value.data diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala index 8c9a8d04..cde9f518 100644 --- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala +++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala @@ -1,9 +1,17 @@ import java.nio.file.Path + +import org.scalajs.ir.Names.DefaultModuleID +import org.scalajs.jsenv.Input +import org.scalajs.linker.interface.ModuleKind import org.scalajs.linker.interface.Report import org.scalajs.linker.interface.unstable import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.fastLinkJS import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.fullLinkJS +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSStage import sbt.* +import sbt.Keys.configuration +import sbt.Keys.crossTarget +import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundle import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildFastLinkJSWrapper import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildFullLinkJSWrapper @@ -114,4 +122,32 @@ package object scalajsesbuild { ) } } + + private[scalajsesbuild] def resolveMainModule( + report: Report + ) = { + report.publicModules + .find(_.moduleID == DefaultModuleID) + .getOrElse( + sys.error( + "Cannot determine `jsEnvInput`: Linking result does not have a " + + s"module named `$DefaultModuleID`. Set jsEnvInput manually?\n" + + s"Full report:\n$report" + ) + ) + } + + private[scalajsesbuild] def jsEnvInputTask = Def.taskDyn { + val stageTask = scalaJSStage.value.stageTask + Def.task { + (stageTask / esbuildBundle).value + + val report = stageTask.value.data + val mainModule = resolveMainModule(report) + + val path = + ((stageTask / esbuildBundle / crossTarget).value / mainModule.jsFileName).toPath + Seq(Input.Script(path)) + } + } }