diff --git a/.gitignore b/.gitignore index b6617b3..67b08f4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ data/ .metals .vscode metals.sbt -.bloop \ No newline at end of file +.bloop +.cursor \ No newline at end of file diff --git a/README.md b/README.md index e35cbfd..fb11317 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,10 @@ browserGeckoTestSettings - [sbt-updates](https://github.com/rtimush/sbt-updates) - [sbt-dependency-check](https://github.com/albuch/sbt-dependency-check) -## Sonatype setup +## JS testing -By default, the plugins use the new `s01.oss.sonatype.org` host for releasing to Sonatype. If your project isn't yet -[migrated](https://central.sonatype.org/news/20210223_new-users-on-s01/), you'll need to add the following to your -root project settings: - -```scala -sonatypeCredentialHost := "oss.sonatype.org" -``` +The browser-test-js contains a fork of [scala-js-env-playwright](https://github.com/gmkumar2005/scala-js-env-playwright), +which is built using Java 11. ## Releasing your library diff --git a/browser-test-js/LICENSE-jsenv b/browser-test-js/LICENSE-jsenv new file mode 100644 index 0000000..634d30d --- /dev/null +++ b/browser-test-js/LICENSE-jsenv @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, gmkumar2005 + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/browser-test-js/src/main/java/jsenv/DriverJar.java b/browser-test-js/src/main/java/jsenv/DriverJar.java new file mode 100644 index 0000000..85dd558 --- /dev/null +++ b/browser-test-js/src/main/java/jsenv/DriverJar.java @@ -0,0 +1,214 @@ +package jsenv; + +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//package com.microsoft.playwright.impl.driver.jar; +// String driverImpl = +// System.getProperty("playwright.driver.impl", "com.microsoft.playwright.impl.driver.jar.DriverJar"); + +import com.microsoft.playwright.impl.driver.Driver; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.*; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class DriverJar extends Driver { + private static final String PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"; + private static final String SELENIUM_REMOTE_URL = "SELENIUM_REMOTE_URL"; + private final Path driverTempDir; + private Path preinstalledNodePath; + + public DriverJar() throws IOException { + // Allow specifying custom path for the driver installation + // See https://github.com/microsoft/playwright-java/issues/728 + String alternativeTmpdir = System.getProperty("playwright.driver.tmpdir"); + String prefix = "playwright-java-"; + driverTempDir = alternativeTmpdir == null + ? Files.createTempDirectory(prefix) + : Files.createTempDirectory(Paths.get(alternativeTmpdir), prefix); + driverTempDir.toFile().deleteOnExit(); + String nodePath = System.getProperty("playwright.nodejs.path"); + if (nodePath != null) { + preinstalledNodePath = Paths.get(nodePath); + if (!Files.exists(preinstalledNodePath)) { + throw new RuntimeException("Invalid Node.js path specified: " + nodePath); + } + } + logMessage("created DriverJar: " + driverTempDir); + } + + @Override + protected void initialize(Boolean installBrowsers) throws Exception { + if (preinstalledNodePath == null && env.containsKey(PLAYWRIGHT_NODEJS_PATH)) { + preinstalledNodePath = Paths.get(env.get(PLAYWRIGHT_NODEJS_PATH)); + if (!Files.exists(preinstalledNodePath)) { + throw new RuntimeException("Invalid Node.js path specified: " + preinstalledNodePath); + } + } else if (preinstalledNodePath != null) { + // Pass the env variable to the driver process. + env.put(PLAYWRIGHT_NODEJS_PATH, preinstalledNodePath.toString()); + } + extractDriverToTempDir(); + logMessage("extracted driver from jar to " + driverDir()); + if (installBrowsers) + installBrowsers(env); + } + + private void installBrowsers(Map env) throws IOException, InterruptedException { + String skip = env.get(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); + if (skip == null) { + skip = System.getenv(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); + } + if (skip != null && !"0".equals(skip) && !"false".equals(skip)) { + logMessage("Skipping browsers download because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` env variable is set"); + return; + } + if (env.get(SELENIUM_REMOTE_URL) != null || System.getenv(SELENIUM_REMOTE_URL) != null) { + logMessage("Skipping browsers download because `SELENIUM_REMOTE_URL` env variable is set"); + return; + } + Path driver = driverDir(); + if (!Files.exists(driver)) { + throw new RuntimeException("Failed to find driver: " + driver); + } + ProcessBuilder pb = createProcessBuilder(); + pb.command().add("install"); + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + Process p = pb.start(); + boolean result = p.waitFor(10, TimeUnit.MINUTES); + if (!result) { + p.destroy(); + throw new RuntimeException("Timed out waiting for browsers to install"); + } + if (p.exitValue() != 0) { + throw new RuntimeException("Failed to install browsers, exit code: " + p.exitValue()); + } + } + + private static boolean isExecutable(Path filePath) { + String name = filePath.getFileName().toString(); + return name.endsWith(".sh") || name.endsWith(".exe") || !name.contains("."); + } + + private FileSystem initFileSystem(URI uri) throws IOException { + try { + return FileSystems.newFileSystem(uri, Collections.emptyMap()); + } catch (FileSystemAlreadyExistsException e) { + return null; + } + } + + public static URI getDriverResourceURI() throws URISyntaxException { + // ClassLoader classloader = Thread.currentThread().getContextClassLoader(); + ClassLoader classloader = DriverJar.class.getClassLoader(); + return classloader.getResource("driver/" + platformDir()).toURI(); + } + + void extractDriverToTempDir() throws URISyntaxException, IOException { + URI originalUri = getDriverResourceURI(); + URI uri = maybeExtractNestedJar(originalUri); + + // Create zip filesystem if loading from jar. + try (FileSystem fileSystem = "jar".equals(uri.getScheme()) ? initFileSystem(uri) : null) { + Path srcRoot = Paths.get(uri); + // jar file system's .relativize gives wrong results when used with + // spring-boot-maven-plugin, convert to the default filesystem to + // have predictable results. + // See https://github.com/microsoft/playwright-java/issues/306 + Path srcRootDefaultFs = Paths.get(srcRoot.toString()); + Files.walk(srcRoot).forEach(fromPath -> { + if (preinstalledNodePath != null) { + String fileName = fromPath.getFileName().toString(); + if ("node.exe".equals(fileName) || "node".equals(fileName)) { + return; + } + } + Path relative = srcRootDefaultFs.relativize(Paths.get(fromPath.toString())); + Path toPath = driverTempDir.resolve(relative.toString()); + try { + if (Files.isDirectory(fromPath)) { + Files.createDirectories(toPath); + } else { + Files.copy(fromPath, toPath); + if (isExecutable(toPath)) { + toPath.toFile().setExecutable(true, true); + } + } + toPath.toFile().deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException("Failed to extract driver from " + uri + ", full uri: " + originalUri, e); + } + }); + } + } + + private URI maybeExtractNestedJar(final URI uri) throws URISyntaxException { + if (!"jar".equals(uri.getScheme())) { + return uri; + } + final String JAR_URL_SEPARATOR = "!/"; + String[] parts = uri.toString().split("!/"); + if (parts.length != 3) { + return uri; + } + String innerJar = String.join(JAR_URL_SEPARATOR, parts[0], parts[1]); + URI jarUri = new URI(innerJar); + try (FileSystem fs = FileSystems.newFileSystem(jarUri, Collections.emptyMap())) { + Path fromPath = Paths.get(jarUri); + Path toPath = driverTempDir.resolve(fromPath.getFileName().toString()); + Files.copy(fromPath, toPath); + toPath.toFile().deleteOnExit(); + return new URI("jar:" + toPath.toUri() + JAR_URL_SEPARATOR + parts[2]); + } catch (IOException e) { + throw new RuntimeException("Failed to extract driver's nested .jar from " + jarUri + "; full uri: " + uri, e); + } + } + + private static String platformDir() { + String name = System.getProperty("os.name").toLowerCase(); + String arch = System.getProperty("os.arch").toLowerCase(); + + if (name.contains("windows")) { + return "win32_x64"; + } + if (name.contains("linux")) { + if (arch.equals("aarch64")) { + return "linux-arm64"; + } else { + return "linux"; + } + } + if (name.contains("mac os x")) { + if (arch.equals("aarch64")) { + return "mac-arm64"; + } else { + return "mac"; + } + } + throw new RuntimeException("Unexpected os.name value: " + name); + } + + @Override + public Path driverDir() { + return driverTempDir; + } +} \ No newline at end of file diff --git a/browser-test-js/src/main/scala/com/softwaremill/SbtSoftwareMillBrowserTestJS.scala b/browser-test-js/src/main/scala/com/softwaremill/SbtSoftwareMillBrowserTestJS.scala index f2ec8b8..3dbac25 100644 --- a/browser-test-js/src/main/scala/com/softwaremill/SbtSoftwareMillBrowserTestJS.scala +++ b/browser-test-js/src/main/scala/com/softwaremill/SbtSoftwareMillBrowserTestJS.scala @@ -5,88 +5,6 @@ import Keys._ import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.{jsEnv, scalaJSLinkerConfig} object SbtSoftwareMillBrowserTestJS { - val downloadChromeDriver: TaskKey[Unit] = taskKey[Unit]( - "Download chrome driver corresponding to installed google-chrome version" - ) - - lazy val downloadGeckoDriver: TaskKey[Unit] = taskKey[Unit]( - "Download gecko driver" - ) - - lazy val geckoDriverVersion: SettingKey[String] = - settingKey[String]("Gecko driver version to download") - - val downloadChromeDriverSettings: Seq[Def.Setting[Task[Unit]]] = Seq( - Global / downloadChromeDriver := { - if (java.nio.file.Files.notExists(new File("target", "chromedriver").toPath)) { - println( - "ChromeDriver binary file not found. Detecting google-chrome version..." - ) - import sys.process._ - val osName = sys.props("os.name") - val isMac = osName.toLowerCase.contains("mac") - val isWin = osName.toLowerCase.contains("win") - val chromeVersionExecutable = - if (isMac) - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - else "google-chrome" - val chromeVersion = Seq(chromeVersionExecutable, "--version").!!.split(' ')(2) - println(s"Detected google-chrome version: $chromeVersion") - val withoutLastPart = chromeVersion.split('.').dropRight(1).mkString(".") - println(s"Selected release: $withoutLastPart") - val latestVersion = IO.readLinesURL(new URL(s"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_$withoutLastPart")).mkString - val platformSuffix = if (isMac) { - if (System.getProperty("os.arch") == "x86_64") "mac-x64" else "mac-arm64" - } else if (isWin) { "win32" } else { "linux64" } - println(s"Downloading chrome driver version $latestVersion for $osName") - IO.unzipURL( - new URL(s"https://storage.googleapis.com/chrome-for-testing-public/$latestVersion/$platformSuffix/chromedriver-$platformSuffix.zip"), - new File("target") - ) - IO.move( - new File(new File("target", s"chromedriver-$platformSuffix"), "chromedriver"), - new File("target", "chromedriver")) - IO.chmod("rwxrwxr-x", new File("target", "chromedriver")) - } else { - println("Detected chromedriver binary file, skipping downloading.") - } - } - ) - - val downloadGeckoDriverSettings: Seq[Def.Setting[_]] = Seq( - Global / geckoDriverVersion := "v0.28.0", - Global / downloadGeckoDriver := { - if (java.nio.file.Files.notExists(new File("target", "geckodriver").toPath)) { - val version = (geckoDriverVersion in Global).value - println(s"geckodriver binary file not found") - import sys.process._ - val osName = sys.props("os.name") - val isMac = osName.toLowerCase.contains("mac") - val isWin = osName.toLowerCase.contains("win") - val platformDependentName = if (isMac) { - "macos.tar.gz" - } else if (isWin) { - "win64.zip" - } else { - "linux64.tar.gz" - } - println(s"Downloading gecko driver version $version for $osName") - val geckoDriverUrl = - s"https://github.com/mozilla/geckodriver/releases/download/$version/geckodriver-$version-$platformDependentName" - if (!isWin) { - url(geckoDriverUrl) #> file("target/geckodriver.tar.gz") #&& - "tar -xz -C target -f target/geckodriver.tar.gz" #&& - "rm target/geckodriver.tar.gz" ! - } else { - IO.unzipURL(new URL(geckoDriverUrl), new File("target")) - } - IO.chmod("rwxrwxr-x", new File("target", "geckodriver")) - } else { - println("Detected geckodriver binary file, skipping downloading.") - } - } - ) - val browserCommonTestSetting: Seq[Def.Setting[_]] = Seq( // https://github.com/scalaz/scalaz/pull/1734#issuecomment-385627061 scalaJSLinkerConfig ~= { @@ -99,56 +17,20 @@ object SbtSoftwareMillBrowserTestJS { ) val browserChromeTestSettings: Seq[Def.Setting[_]] = - downloadChromeDriverSettings ++ browserCommonTestSetting ++ Seq( - jsEnv in Test := { - val debugging = false // set to true to help debugging - System.setProperty("webdriver.chrome.driver", "target/chromedriver") - new org.scalajs.jsenv.selenium.SeleniumJSEnv( - { - val options = new org.openqa.selenium.chrome.ChromeOptions() - val args = Seq( - "auto-open-devtools-for-tabs", // devtools needs to be open to capture network requests - "no-sandbox", - "allow-file-access-from-files" // change the origin header from 'null' to 'file' - ) ++ (if (debugging) Seq.empty else Seq("headless")) - options.addArguments(args: _*) - val capabilities = - org.openqa.selenium.remote.DesiredCapabilities.chrome() - capabilities.setCapability( - org.openqa.selenium.chrome.ChromeOptions.CAPABILITY, - options - ) - capabilities - }, - org.scalajs.jsenv.selenium.SeleniumJSEnv - .Config() - .withKeepAlive(debugging) - ) - }, - test in Test := (test in Test) - .dependsOn(downloadChromeDriver) - .value + browserCommonTestSetting ++ Seq( + Test / jsEnv := new jsenv.playwright.PWEnv( + browserName = "chrome", + headless = true, + showLogs = true + ) ) val browserGeckoTestSettings: Seq[Def.Setting[_]] = - downloadGeckoDriverSettings ++ browserCommonTestSetting ++ Seq( - jsEnv in Test := { - val debugging = false // set to true to help debugging - System.setProperty("webdriver.gecko.driver", "target/geckodriver") - new org.scalajs.jsenv.selenium.SeleniumJSEnv( - { - val options = new org.openqa.selenium.firefox.FirefoxOptions() - val args = (if (debugging) Seq("--devtools") else Seq("-headless")) - options.addArguments(args: _*) - options - }, - org.scalajs.jsenv.selenium.SeleniumJSEnv - .Config() - .withKeepAlive(debugging) - ) - }, - test in Test := (test in Test) - .dependsOn(downloadGeckoDriver) - .value + browserCommonTestSetting ++ Seq( + Test / jsEnv := new jsenv.playwright.PWEnv( + browserName = "firefox", + headless = true, + showLogs = true + ) ) } diff --git a/browser-test-js/src/main/scala/jsenv/playwright/CEComRun.scala b/browser-test-js/src/main/scala/jsenv/playwright/CEComRun.scala new file mode 100644 index 0000000..41e4f05 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/CEComRun.scala @@ -0,0 +1,38 @@ +package jsenv.playwright + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import jsenv.playwright.PWEnv.Config +import org.scalajs.jsenv.Input +import org.scalajs.jsenv.JSComRun +import org.scalajs.jsenv.RunConfig + +import scala.concurrent._ + +// browserName, headless, pwConfig, runConfig, input, onMessage +class CEComRun( + override val browserName: String, + override val headless: Boolean, + override val pwConfig: Config, + override val runConfig: RunConfig, + override val input: Seq[Input], + override val launchOptions: List[String], + override val additionalLaunchOptions: List[String], + onMessage: String => Unit +) extends JSComRun + with Runner { + scribe.debug(s"Creating CEComRun for $browserName") + // enableCom is false for CERun and true for CEComRun + // send is called only from JSComRun + override def send(msg: String): Unit = sendQueue.offer(msg) + // receivedMessage is called only from JSComRun. Hence its implementation is empty in CERun + override protected def receivedMessage(msg: String): Unit = onMessage(msg) + + lazy val future: Future[Unit] = + jsRunPrg(browserName, headless, isComEnabled = true, pwLaunchOptions) + .use(_ => IO.unit) + .unsafeToFuture() + +} + +private class WindowOnErrorException(errs: List[String]) extends Exception(s"JS error: $errs") diff --git a/browser-test-js/src/main/scala/jsenv/playwright/CERun.scala b/browser-test-js/src/main/scala/jsenv/playwright/CERun.scala new file mode 100644 index 0000000..6449e7d --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/CERun.scala @@ -0,0 +1,29 @@ +package jsenv.playwright + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import jsenv.playwright.PWEnv.Config +import org.scalajs.jsenv.Input +import org.scalajs.jsenv.JSRun +import org.scalajs.jsenv.RunConfig + +import scala.concurrent._ + +class CERun( + override val browserName: String, + override val headless: Boolean, + override val pwConfig: Config, + override val runConfig: RunConfig, + override val input: Seq[Input], + override val launchOptions: List[String], + override val additionalLaunchOptions: List[String] +) extends JSRun + with Runner { + scribe.debug(s"Creating CERun for $browserName") + lazy val future: Future[Unit] = + jsRunPrg(browserName, headless, isComEnabled = false, pwLaunchOptions) + .use(_ => IO.unit) + .unsafeToFuture() + + override protected def receivedMessage(msg: String): Unit = () +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/CEUtils.scala b/browser-test-js/src/main/scala/jsenv/playwright/CEUtils.scala new file mode 100644 index 0000000..f3bcbd8 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/CEUtils.scala @@ -0,0 +1,69 @@ +package jsenv.playwright + +import org.scalajs.jsenv.Input +import org.scalajs.jsenv.UnsupportedInputException +import scribe.format.FormatterInterpolator +import scribe.format.classNameSimple +import scribe.format.dateFull +import scribe.format.level +import scribe.format.mdc +import scribe.format.messages +import scribe.format.methodName +import scribe.format.threadName + +import java.nio.file.Path + +object CEUtils { + def htmlPage( + fullInput: Seq[Input], + materializer: FileMaterializer + ): String = { + val tags = fullInput.map { + case Input.Script(path) => makeTag(path, "text/javascript", materializer) + case Input.CommonJSModule(path) => + makeTag(path, "text/javascript", materializer) + case Input.ESModule(path) => makeTag(path, "module", materializer) + case _ => throw new UnsupportedInputException(fullInput) + } + + s""" + | + | + | ${tags.mkString("\n ")} + | + | + """.stripMargin + } + + private def makeTag( + path: Path, + tpe: String, + materializer: FileMaterializer + ): String = { + val url = materializer.materialize(path) + s"" + } + + def setupLogger(showLogs: Boolean, debug: Boolean): Unit = { + val formatter = + formatter"$dateFull [$threadName] $classNameSimple $level $methodName - $messages$mdc" + scribe + .Logger + .root + .clearHandlers() + .withHandler( + formatter = formatter + ) + .replace() + // default log level is error + scribe.Logger.root.withMinimumLevel(scribe.Level.Error).replace() + + if (showLogs) { + scribe.Logger.root.withMinimumLevel(scribe.Level.Info).replace() + } + if (debug) { + scribe.Logger.root.withMinimumLevel(scribe.Level.Trace).replace() + } + } + +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/FileMaterializers.scala b/browser-test-js/src/main/scala/jsenv/playwright/FileMaterializers.scala new file mode 100644 index 0000000..9207561 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/FileMaterializers.scala @@ -0,0 +1,88 @@ +package jsenv.playwright + +import java.net._ +import java.nio.file._ +import java.util + +abstract class FileMaterializer extends AutoCloseable { + private val tmpSuffixRE = """[a-zA-Z0-9-_.]*$""".r + + private var tmpFiles: List[Path] = Nil + + def materialize(path: Path): URL = { + val tmp = newTmp(path.toString) + // if file with extension .map exist then copy it too + val mapPath = Paths.get(path.toString + ".map") + Files.copy(path, tmp, StandardCopyOption.REPLACE_EXISTING) + if (Files.exists(mapPath)) { + val tmpMap = newTmp(mapPath.toString) + Files.copy(mapPath, tmpMap, StandardCopyOption.REPLACE_EXISTING) + } + toURL(tmp) + } + + final def materialize(name: String, content: String): URL = { + val tmp = newTmp(name) + Files.write(tmp, util.Arrays.asList(content)) + toURL(tmp) + } + + final def close(): Unit = { + tmpFiles.foreach(Files.delete) + tmpFiles = Nil + } + + private def newTmp(path: String): Path = { + val suffix = tmpSuffixRE.findFirstIn(path).orNull + val p = createTmp(suffix) + tmpFiles ::= p + p + } + + protected def createTmp(suffix: String): Path + protected def toURL(file: Path): URL +} + +object FileMaterializer { + import PWEnv.Config.Materialization + def apply(m: Materialization): FileMaterializer = m match { + case Materialization.Temp => + new TempDirFileMaterializer + + case Materialization.Server(contentDir, webRoot) => + new ServerDirFileMaterializer(contentDir, webRoot) + } +} + +/** + * materializes virtual files in a temp directory (uses file:// schema). + */ +private class TempDirFileMaterializer extends FileMaterializer { + override def materialize(path: Path): URL = { + try { + path.toFile.toURI.toURL + } catch { + case _: UnsupportedOperationException => + super.materialize(path) + } + } + + protected def createTmp(suffix: String): Path = + Files.createTempFile(null, suffix) + protected def toURL(file: Path): URL = file.toUri.toURL +} + +private class ServerDirFileMaterializer(contentDir: Path, webRoot: URL) + extends FileMaterializer { + Files.createDirectories(contentDir) + + protected def createTmp(suffix: String): Path = + Files.createTempFile(contentDir, null, suffix) + + protected def toURL(file: Path): URL = { + val rel = contentDir.relativize(file) + assert(!rel.isAbsolute) + val nameURI = new URI(null, null, rel.toString, null) + webRoot.toURI.resolve(nameURI).toURL + } +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/JSSetup.scala b/browser-test-js/src/main/scala/jsenv/playwright/JSSetup.scala new file mode 100644 index 0000000..af40d18 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/JSSetup.scala @@ -0,0 +1,93 @@ +package jsenv.playwright + +import com.google.common.jimfs.Jimfs + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +private object JSSetup { + def setupFile(enableCom: Boolean): Path = { + val path = Jimfs.newFileSystem().getPath("setup.js") + val contents = setupCode(enableCom).getBytes(StandardCharsets.UTF_8) + Files.write(path, contents) + } + + private def setupCode(enableCom: Boolean): String = { + s""" + |(function() { + | // Buffers for console.log / console.error + | var consoleLog = []; + | var consoleError = []; + | + | // Buffer for errors. + | var errors = []; + | + | // Buffer for outgoing messages. + | var outMessages = []; + | + | // Buffer for incoming messages (used if onMessage not initalized). + | var inMessages = []; + | + | // Callback for incoming messages. + | var onMessage = null; + | + | function captureConsole(fun, buf) { + | if (!fun) return fun; + | return function() { + | var strs = [] + | for (var i = 0; i < arguments.length; ++i) + | strs.push(String(arguments[i])); + | + | buf.push(strs.join(" ")); + | return fun.apply(this, arguments); + | } + | } + | + | console.log = captureConsole(console.log, consoleLog); + | console.error = captureConsole(console.error, consoleError); + | + | window.addEventListener('error', function(e) { + | errors.push(e.message) + | }); + | + | if ($enableCom) { + | this.scalajsCom = { + | init: function(onMsg) { + | onMessage = onMsg; + | window.setTimeout(function() { + | for (var m in inMessages) + | onMessage(inMessages[m]); + | inMessages = null; + | }); + | }, + | send: function(msg) { outMessages.push(msg); } + | } + | } + | + | this.scalajsPlayWrightInternalInterface = { + | fetch: function() { + | var res = { + | consoleLog: consoleLog.slice(), + | consoleError: consoleError.slice(), + | errors: errors.slice(), + | msgs: outMessages.slice() + | } + | + | consoleLog.length = 0; + | consoleError.length = 0; + | errors.length = 0; + | outMessages.length = 0; + | + | return res; + | }, + | send: function(msg) { + | if (inMessages !== null) inMessages.push(msg); + | else onMessage(msg); + | } + | }; + |}).call(this) + """.stripMargin + } + +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/OutputStreams.scala b/browser-test-js/src/main/scala/jsenv/playwright/OutputStreams.scala new file mode 100644 index 0000000..914a777 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/OutputStreams.scala @@ -0,0 +1,40 @@ +package jsenv.playwright + +import org.scalajs.jsenv.RunConfig + +import java.io._ + +object OutputStreams { + final class Streams(val out: PrintStream, val err: PrintStream) { + def close(): Unit = { + out.close() + err.close() + } + } + + def prepare(config: RunConfig): Streams = { + val outp = optPipe(!config.inheritOutput) + val errp = optPipe(!config.inheritError) + + config.onOutputStream.foreach(f => f(outp.map(_._1), errp.map(_._1))) + + val out = outp.fold[OutputStream](new UnownedOutputStream(System.out))(_._2) + val err = errp.fold[OutputStream](new UnownedOutputStream(System.err))(_._2) + + new Streams(new PrintStream(out), new PrintStream(err)) + } + + private def optPipe(want: Boolean) = { + if (want) { + val i = new PipedInputStream() + val o = new PipedOutputStream(i) + Some((i, o)) + } else { + None + } + } + + private class UnownedOutputStream(out: OutputStream) extends FilterOutputStream(out) { + override def close(): Unit = flush() + } +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/PWEnv.scala b/browser-test-js/src/main/scala/jsenv/playwright/PWEnv.scala new file mode 100644 index 0000000..9c95567 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/PWEnv.scala @@ -0,0 +1,195 @@ +package jsenv.playwright + +import jsenv.playwright.PWEnv.Config +import org.scalajs.jsenv._ + +import java.net.URI +import java.net.URL +import java.nio.file.Path +import java.nio.file.Paths +import scala.util.control.NonFatal + +/** + * Playwright JS environment + * + * @param browserName + * browser name, options are "chromium", "chrome", "firefox", "webkit", default is "chromium" + * @param headless + * headless mode, default is true + * @param showLogs + * show logs, default is false + * @param debug + * debug mode, default is false + * @param pwConfig + * Playwright configuration + * @param launchOptions + * override launch options, if not provided default launch options are used + * @param additionalLaunchOptions + * additional launch options (added to (default) launch options) + */ +class PWEnv( + browserName: String = "chromium", + headless: Boolean = true, + showLogs: Boolean = false, + debug: Boolean = false, + pwConfig: Config = Config(), + launchOptions: List[String] = Nil, + additionalLaunchOptions: List[String] = Nil +) extends JSEnv { + + private lazy val validator = { + RunConfig.Validator().supportsInheritIO().supportsOnOutputStream() + } + override val name: String = s"CEEnv with $browserName" + System.setProperty("playwright.driver.impl", "jsenv.DriverJar") + CEUtils.setupLogger(showLogs, debug) + + override def start(input: Seq[Input], runConfig: RunConfig): JSRun = { + try { + validator.validate(runConfig) + new CERun( + browserName, + headless, + pwConfig, + runConfig, + input, + launchOptions, + additionalLaunchOptions) + } catch { + case ve: java.lang.IllegalArgumentException => + scribe.error(s"CEEnv.startWithCom failed with throw ve $ve") + throw ve + case NonFatal(t) => + scribe.error(s"CEEnv.start failed with $t") + JSRun.failed(t) + } + } + + override def startWithCom( + input: Seq[Input], + runConfig: RunConfig, + onMessage: String => Unit + ): JSComRun = { + try { + validator.validate(runConfig) + new CEComRun( + browserName, + headless, + pwConfig, + runConfig, + input, + launchOptions, + additionalLaunchOptions, + onMessage + ) + } catch { + case ve: java.lang.IllegalArgumentException => + scribe.error(s"CEEnv.startWithCom failed with throw ve $ve") + throw ve + case NonFatal(t) => + scribe.error(s"CEEnv.startWithCom failed with $t") + JSComRun.failed(t) + } + } + +} + +object PWEnv { + case class Config( + materialization: Config.Materialization = Config.Materialization.Temp + ) { + import Config.Materialization + + /** + * Materializes purely virtual files into a temp directory. + * + * Materialization is necessary so that virtual files can be referred to by name. If you do + * not know/care how your files are referred to, this is a good default choice. It is also + * the default of [[PWEnv.Config]]. + */ + def withMaterializeInTemp: Config = + copy(materialization = Materialization.Temp) + + /** + * Materializes files in a static directory of a user configured server. + * + * This can be used to bypass cross origin access policies. + * + * @param contentDir + * Static content directory of the server. The files will be put here. Will get created if + * it doesn't exist. + * @param webRoot + * URL making `contentDir` accessible thorugh the server. This must have a trailing slash + * to be interpreted as a directory. + * + * @example + * + * The following will make the browser fetch files using the http:// schema instead of the + * file:// schema. The example assumes a local webserver is running and serving the ".tmp" + * directory at http://localhost:8080. + * + * {{{ + * jsSettings( + * jsEnv := new SeleniumJSEnv( + * new org.openqa.selenium.firefox.FirefoxOptions(), + * SeleniumJSEnv.Config() + * .withMaterializeInServer(".tmp", "http://localhost:8080/") + * ) + * ) + * }}} + */ + def withMaterializeInServer(contentDir: String, webRoot: String): Config = + withMaterializeInServer(Paths.get(contentDir), new URI(webRoot).toURL) + + /** + * Materializes files in a static directory of a user configured server. + * + * Version of `withMaterializeInServer` with stronger typing. + * + * @param contentDir + * Static content directory of the server. The files will be put here. Will get created if + * it doesn't exist. + * @param webRoot + * URL making `contentDir` accessible thorugh the server. This must have a trailing slash + * to be interpreted as a directory. + */ + def withMaterializeInServer(contentDir: Path, webRoot: URL): Config = + copy(materialization = Materialization.Server(contentDir, webRoot)) + + def withMaterialization(materialization: Materialization): Config = + copy(materialization = materialization) + } + + object Config { + + abstract class Materialization private () + object Materialization { + final case object Temp extends Materialization + final case class Server(contentDir: Path, webRoot: URL) extends Materialization { + require( + webRoot.getPath.endsWith("/"), + "webRoot must end with a slash (/)" + ) + } + } + } + + val chromeLaunchOptions = List( + "--disable-extensions", + "--disable-web-security", + "--allow-running-insecure-content", + "--disable-site-isolation-trials", + "--allow-file-access-from-files", + "--disable-gpu" + ) + + val firefoxLaunchOptions = List("--disable-web-security") + + val webkitLaunchOptions = List( + "--disable-extensions", + "--disable-web-security", + "--allow-running-insecure-content", + "--disable-site-isolation-trials", + "--allow-file-access-from-files" + ) +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/PageFactory.scala b/browser-test-js/src/main/scala/jsenv/playwright/PageFactory.scala new file mode 100644 index 0000000..920306e --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/PageFactory.scala @@ -0,0 +1,74 @@ +package jsenv.playwright + +import cats.effect.IO +import cats.effect.Resource +import com.microsoft.playwright.Browser +import com.microsoft.playwright.BrowserType +import com.microsoft.playwright.BrowserType.LaunchOptions +import com.microsoft.playwright.Page +import com.microsoft.playwright.Playwright + +object PageFactory { + def pageBuilder(browser: Browser): Resource[IO, Page] = { + Resource.make(IO { + val pg = browser.newContext().newPage() + scribe.debug(s"Creating page ${pg.hashCode()} ") + pg + })(page => IO { page.close() }) + } + + private def browserBuilder( + playwright: Playwright, + browserName: String, + headless: Boolean, + launchOptions: LaunchOptions + ): Resource[IO, Browser] = + Resource.make(IO { + + val browserType: BrowserType = browserName.toLowerCase match { + case "chromium" | "chrome" => + playwright.chromium() + case "firefox" => + playwright.firefox() + case "webkit" => + playwright.webkit() + case _ => throw new IllegalArgumentException("Invalid browser type") + } + val browser = browserType.launch(launchOptions.setHeadless(headless)) + scribe.info( + s"Creating browser ${browser.browserType().name()} version ${browser.version()} with ${browser.hashCode()}" + ) + browser + })(browser => + IO { + scribe.debug(s"Closing browser with ${browser.hashCode()}") + browser.close() + }) + + private def playWrightBuilder: Resource[IO, Playwright] = + Resource.make(IO { + scribe.debug(s"Creating playwright") + Playwright.create() + })(pw => + IO { + scribe.debug("Closing playwright") + pw.close() + }) + + def createPage( + browserName: String, + headless: Boolean, + launchOptions: LaunchOptions + ): Resource[IO, Page] = + for { + playwright <- playWrightBuilder + browser <- browserBuilder( + playwright, + browserName, + headless, + launchOptions + ) + page <- pageBuilder(browser) + } yield page + +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/ResourcesFactory.scala b/browser-test-js/src/main/scala/jsenv/playwright/ResourcesFactory.scala new file mode 100644 index 0000000..bceb04b --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/ResourcesFactory.scala @@ -0,0 +1,168 @@ +package jsenv.playwright + +import cats.effect.IO +import cats.effect.Resource +import com.microsoft.playwright.Page +import jsenv.playwright.PWEnv.Config +import org.scalajs.jsenv.Input +import org.scalajs.jsenv.RunConfig + +import java.util +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Consumer +import scala.annotation.tailrec +import scala.concurrent.duration.DurationInt + +object ResourcesFactory { + def preparePageForJsRun( + pageInstance: Page, + materializerResource: Resource[IO, FileMaterializer], + input: Seq[Input], + enableCom: Boolean + ): Resource[IO, Unit] = + for { + m <- materializerResource + _ <- Resource.pure( + scribe.debug(s"Page instance is ${pageInstance.hashCode()}") + ) + _ <- Resource.pure { + val setupJsScript = Input.Script(JSSetup.setupFile(enableCom)) + val fullInput = setupJsScript +: input + val materialPage = + m.materialize( + "scalajsRun.html", + CEUtils.htmlPage(fullInput, m) + ) + pageInstance.navigate(materialPage.toString) + } + } yield () + + private def fetchMessages( + pageInstance: Page, + intf: String + ): java.util.Map[String, java.util.List[String]] = { + val data = + pageInstance + .evaluate(s"$intf.fetch();") + .asInstanceOf[java.util.Map[String, java.util.List[String]]] + data + } + + def processUntilStop( + stopSignal: AtomicBoolean, + pageInstance: Page, + intf: String, + sendQueue: ConcurrentLinkedQueue[String], + outStream: OutputStreams.Streams, + receivedMessage: String => Unit + ): Resource[IO, Unit] = { + Resource.pure[IO, Unit] { + scribe.debug(s"Started processUntilStop") + while (!stopSignal.get()) { + sendAll(sendQueue, pageInstance, intf) + val jsResponse = fetchMessages(pageInstance, intf) + streamWriter(jsResponse, outStream, Some(receivedMessage)) + IO.sleep(100.milliseconds) + } + scribe.debug(s"Stop processUntilStop") + } + } + + def isConnectionUp( + pageInstance: Page, + intf: String + ): Resource[IO, Boolean] = { + Resource.pure[IO, Boolean] { + val status = pageInstance.evaluate(s"!!$intf;").asInstanceOf[Boolean] + scribe.debug( + s"Page instance is ${pageInstance.hashCode()} with status $status" + ) + status + } + + } + + def materializer(pwConfig: Config): Resource[IO, FileMaterializer] = + Resource.make { + IO.blocking(FileMaterializer(pwConfig.materialization)) // build + } { fileMaterializer => + IO { + scribe.debug("Closing the fileMaterializer") + fileMaterializer.close() + }.handleErrorWith(_ => { + scribe.error("Error in closing the fileMaterializer") + IO.unit + }) // release + } + + /* + * Creates resource for outputStream + */ + def outputStream( + runConfig: RunConfig + ): Resource[IO, OutputStreams.Streams] = + Resource.make { + IO.blocking(OutputStreams.prepare(runConfig)) // build + } { outStream => + IO { + scribe.debug(s"Closing the stream ${outStream.hashCode()}") + outStream.close() + }.handleErrorWith(_ => { + scribe.error(s"Error in closing the stream ${outStream.hashCode()})") + IO.unit + }) // release + } + + private def streamWriter( + jsResponse: util.Map[String, util.List[String]], + outStream: OutputStreams.Streams, + onMessage: Option[String => Unit] = None + ): Unit = { + val data = jsResponse.get("consoleLog") + val consoleError = jsResponse.get("consoleError") + val error = jsResponse.get("errors") + onMessage match { + case Some(f) => + val msgs = jsResponse.get("msgs") + msgs.forEach(consumer(f)) + case None => scribe.debug("No onMessage function") + } + data.forEach(outStream.out.println _) + error.forEach(outStream.out.println _) + consoleError.forEach(outStream.out.println _) + + if (!error.isEmpty) { + val errList = error.toArray(Array[String]()).toList + throw new WindowOnErrorException(errList) + } + } + + @tailrec + def sendAll( + sendQueue: ConcurrentLinkedQueue[String], + pageInstance: Page, + intf: String + ): Unit = { + val msg = sendQueue.poll() + if (msg != null) { + scribe.debug(s"Sending message") + val script = s"$intf.send(arguments[0]);" + val wrapper = s"function(arg) { $script }" + pageInstance.evaluate(s"$wrapper", msg) + val pwDebug = sys.env.getOrElse("PWDEBUG", "0") + if (pwDebug == "1") { + pageInstance.pause() + } + sendAll(sendQueue, pageInstance, intf) + } + } + private def consumer[A](f: A => Unit): Consumer[A] = (v: A) => f(v) + private def logStackTrace(): Unit = { + try { + throw new Exception("Logging stack trace") + } catch { + case e: Exception => e.printStackTrace() + } + } +} diff --git a/browser-test-js/src/main/scala/jsenv/playwright/Runner.scala b/browser-test-js/src/main/scala/jsenv/playwright/Runner.scala new file mode 100644 index 0000000..6f5abb5 --- /dev/null +++ b/browser-test-js/src/main/scala/jsenv/playwright/Runner.scala @@ -0,0 +1,156 @@ +package jsenv.playwright + +import cats.effect.IO +import cats.effect.Resource +import com.microsoft.playwright.BrowserType +import com.microsoft.playwright.BrowserType.LaunchOptions +import jsenv.playwright.PWEnv.Config +import jsenv.playwright.PageFactory._ +import jsenv.playwright.ResourcesFactory._ +import org.scalajs.jsenv.Input +import org.scalajs.jsenv.RunConfig + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import scala.concurrent.duration.DurationInt +import scala.jdk.CollectionConverters.seqAsJavaListConverter + +trait Runner { + val browserName: String = "" // or provide actual values + val headless: Boolean = false // or provide actual values + val pwConfig: Config = Config() // or provide actual values + val runConfig: RunConfig = RunConfig() // or provide actual values + val input: Seq[Input] = Seq.empty // or provide actual values + val launchOptions: List[String] = Nil + val additionalLaunchOptions: List[String] = Nil + + // enableCom is false for CERun and true for CEComRun + protected val enableCom = false + protected val intf = "this.scalajsPlayWrightInternalInterface" + protected val sendQueue = new ConcurrentLinkedQueue[String] + // receivedMessage is called only from JSComRun. Hence its implementation is empty in CERun + protected def receivedMessage(msg: String): Unit + var wantToClose = new AtomicBoolean(false) + // List of programs + // 1. isInterfaceUp() + // Create PW resource if not created. Create browser,context and page + // 2. Sleep + // 3. wantClose + // 4. sendAll() + // 5. fetchAndProcess() + // 6. Close diver + // 7. Close streams + // 8. Close materializer + // Flow + // if interface is down and dont want to close wait for 100 milliseconds + // interface is up and dont want to close sendAll(), fetchAndProcess() Sleep for 100 milliseconds + // If want to close then close driver, streams, materializer + // After future is completed close driver, streams, materializer + + def jsRunPrg( + browserName: String, + headless: Boolean, + isComEnabled: Boolean, + launchOptions: LaunchOptions + ): Resource[IO, Unit] = for { + _ <- Resource.pure( + scribe.info( + s"Begin Main with isComEnabled $isComEnabled " + + s"and browserName $browserName " + + s"and headless is $headless " + ) + ) + pageInstance <- createPage( + browserName, + headless, + launchOptions + ) + _ <- preparePageForJsRun( + pageInstance, + materializer(pwConfig), + input, + isComEnabled + ) + connectionReady <- isConnectionUp(pageInstance, intf) + _ <- + if (!connectionReady) Resource.pure[IO, Unit] { + IO.sleep(100.milliseconds) + } + else Resource.pure[IO, Unit](IO.unit) + _ <- + if (!connectionReady) isConnectionUp(pageInstance, intf) + else Resource.pure[IO, Unit](IO.unit) + out <- outputStream(runConfig) + _ <- processUntilStop( + wantToClose, + pageInstance, + intf, + sendQueue, + out, + receivedMessage + ) + } yield () + + /** + * Stops the run and releases all the resources. + * + * This must be called to ensure the run's resources are released. + * + * Whether or not this makes the run fail or not is up to the implementation. However, in the + * following cases, calling [[close]] may not fail the run: + * + * Idempotent, async, nothrow. + */ + + def close(): Unit = { + wantToClose.set(true) + scribe.debug(s"Received stopSignal ${wantToClose.get()}") + } + + def getCaller: String = { + val stackTraceElements = Thread.currentThread().getStackTrace + if (stackTraceElements.length > 5) { + val callerElement = stackTraceElements(5) + s"Caller class: ${callerElement.getClassName}, method: ${callerElement.getMethodName}" + } else { + "Could not determine caller." + } + } + + def logStackTrace(): Unit = { + try { + throw new Exception("Logging stack trace") + } catch { + case e: Exception => e.printStackTrace() + } + } + + protected lazy val pwLaunchOptions = + browserName.toLowerCase() match { + case "chromium" | "chrome" => + new BrowserType.LaunchOptions().setArgs( + if (launchOptions.isEmpty) + (PWEnv.chromeLaunchOptions ++ additionalLaunchOptions).asJava + else (launchOptions ++ additionalLaunchOptions).asJava + ) + case "firefox" => + new BrowserType.LaunchOptions().setArgs( + if (launchOptions.isEmpty) + (PWEnv.firefoxLaunchOptions ++ additionalLaunchOptions).asJava + else (launchOptions ++ additionalLaunchOptions).asJava + ) + case "webkit" => + new BrowserType.LaunchOptions().setArgs( + if (launchOptions.isEmpty) + (PWEnv.webkitLaunchOptions ++ additionalLaunchOptions).asJava + else (launchOptions ++ additionalLaunchOptions).asJava + ) + case _ => throw new IllegalArgumentException("Invalid browser type") + } + +} + +//private class WindowOnErrorException(errs: List[String]) +// extends Exception(s"JS error: $errs") diff --git a/build.sbt b/build.sbt index 84ba535..e98735c 100644 --- a/build.sbt +++ b/build.sbt @@ -84,7 +84,14 @@ lazy val browserTestJs = project sbtPlugin := true, scriptedLaunchOpts += ("-Dplugin.version=" + version.value) ) - .settings( - libraryDependencies += "org.scala-js" %% "scalajs-env-selenium" % "1.1.1", - addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") + .settings( + addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0"), + // playwright dependencies, copied from https://github.com/gmkumar2005/scala-js-env-playwright/blob/main/build.sbt + libraryDependencies ++= Seq( + "com.microsoft.playwright" % "playwright" % "1.49.0", + "org.scala-js" %% "scalajs-js-envs" % "1.4.0", + "com.google.jimfs" % "jimfs" % "1.3.0", + "com.outr" %% "scribe" % "3.15.2", + "org.typelevel" %% "cats-effect" % "3.5.7" + ) )