├── project ├── build.properties └── plugins.sbt ├── version.sbt ├── .scalafix.conf ├── src ├── test │ └── scala │ │ └── jsenv │ │ └── playwright │ │ ├── jstest.js │ │ ├── PWSuiteChrome.scala │ │ ├── PWSuiteFirefox.scala │ │ ├── PWSuiteWebKit.scala │ │ ├── SimplePage.html │ │ ├── simple.js │ │ └── RunTests.scala └── main │ ├── scala │ └── jsenv │ │ └── playwright │ │ ├── CERun.scala │ │ ├── OutputStreams.scala │ │ ├── CEComRun.scala │ │ ├── CEUtils.scala │ │ ├── PageFactory.scala │ │ ├── FileMaterializers.scala │ │ ├── JSSetup.scala │ │ ├── ResourcesFactory.scala │ │ ├── Runner.scala │ │ └── PWEnv.scala │ └── java │ └── jsenv │ └── DriverJar.java ├── .github └── workflows │ ├── dependency-graph.md │ └── scala.yml ├── .gitignore ├── SimpleCommands.md ├── .scalafmt.conf ├── LICENSE └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.7 2 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "0.1.18-SNAPSHOT" 2 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [OrganizeImports] 2 | 3 | OrganizeImports.removeUnused = false 4 | -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/jstest.js: -------------------------------------------------------------------------------- 1 | 2 | scalajsCom.init(function(msg) { scalajsCom.send("received: " + msg); }); 3 | scalajsCom.send("Hello World"); 4 | -------------------------------------------------------------------------------- /.github/workflows/dependency-graph.md: -------------------------------------------------------------------------------- 1 | ## in .github/workflows/dependency-graph.md 2 | ... 3 | permissions: 4 | contents: write # this permission is needed to submit the dependency graph 5 | ... -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/PWSuiteChrome.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import org.junit.runner.RunWith 4 | import org.scalajs.jsenv.test._ 5 | 6 | @RunWith(classOf[JSEnvSuiteRunner]) 7 | class PWSuiteChrome 8 | extends JSEnvSuite( 9 | JSEnvSuiteConfig(new PWEnv("chrome", debug = false, headless = true)) 10 | ) 11 | -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/PWSuiteFirefox.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import org.junit.runner.RunWith 4 | import org.scalajs.jsenv.test._ 5 | 6 | @RunWith(classOf[JSEnvSuiteRunner]) 7 | class PWSuiteFirefox 8 | extends JSEnvSuite( 9 | JSEnvSuiteConfig(new PWEnv("firefox", debug = false, headless = true)) 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | *.class 3 | *.log 4 | 5 | # sbt specific 6 | .cache 7 | .history 8 | .lib/ 9 | dist/* 10 | target/ 11 | lib_managed/ 12 | src_managed/ 13 | project/boot/ 14 | project/plugins/project/ 15 | 16 | # Scala-IDE specific 17 | .scala_dependencies 18 | .worksheet 19 | .idea 20 | .metals 21 | .lh 22 | .bloop 23 | .bsp 24 | .vscode 25 | .DS_Store 26 | ``` -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/PWSuiteWebKit.scala: -------------------------------------------------------------------------------- 1 | // package jsenv.playwright 2 | 3 | // import org.junit.runner.RunWith 4 | // import org.scalajs.jsenv.test._ 5 | 6 | // @RunWith(classOf[JSEnvSuiteRunner]) 7 | // class PWSuiteWebKit 8 | // extends JSEnvSuite( 9 | // JSEnvSuiteConfig(new PWEnv("webkit", debug = false, headless = true)) 10 | // ) 11 | -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/SimplePage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | val sbtTypelevelVersion = "0.7.5" 2 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0") 3 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0") 4 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") 5 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2") 6 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.2.2") 7 | addSbtPlugin("org.typelevel" % "sbt-typelevel" % sbtTypelevelVersion) 8 | addSbtPlugin("org.typelevel" % "sbt-typelevel-scalafix" % sbtTypelevelVersion) 9 | addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % sbtTypelevelVersion) 10 | 11 | addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.6") 12 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/CERun.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import jsenv.playwright.PWEnv.Config 6 | import org.scalajs.jsenv.Input 7 | import org.scalajs.jsenv.JSRun 8 | import org.scalajs.jsenv.RunConfig 9 | 10 | import scala.concurrent._ 11 | 12 | class CERun( 13 | override val browserName: String, 14 | override val headless: Boolean, 15 | override val pwConfig: Config, 16 | override val runConfig: RunConfig, 17 | override val input: Seq[Input], 18 | override val launchOptions: List[String], 19 | override val additionalLaunchOptions: List[String] 20 | ) extends JSRun 21 | with Runner { 22 | scribe.debug(s"Creating CERun for $browserName") 23 | lazy val future: Future[Unit] = 24 | jsRunPrg(browserName, headless, isComEnabled = false, pwLaunchOptions) 25 | .use(_ => IO.unit) 26 | .unsafeToFuture() 27 | 28 | override protected def receivedMessage(msg: String): Unit = () 29 | } 30 | -------------------------------------------------------------------------------- /SimpleCommands.md: -------------------------------------------------------------------------------- 1 | ### Release process 2 | 3 | #### Verify gpg is working 4 | 5 | ```sh 6 | echo "test" | gpg --clearsign -u F7E440260BAE93EB4AD2723D6613CA76E011F638 7 | ``` 8 | 9 | ```text 10 | export SONATYPE_USERNAME=<> 11 | export SONATYPE_PASSWORD=<> 12 | ``` 13 | 14 | #### And in your credentials file (~/.sbt/1.0/sonatype.sbt): 15 | 16 | ``` 17 | credentials += Credentials( 18 | "Sonatype Nexus Repository Manager", 19 | "s01.oss.sonatype.org", // Note the s01 subdomain 20 | "your-username", 21 | "your-password" 22 | ) 23 | ``` 24 | 25 | #### Copy sonatype.sbt to sonatype.credentials 26 | 27 | ``` 28 | cp sonatype.sbt sonatype.credentials 29 | ``` 30 | ### Run sbt commands 31 | ```sh 32 | sbt release 33 | *** Current release and next release has to be same. 34 | 35 | sbt sonatypeBundleRelease 36 | 37 | ``` 38 | 39 | ### Verify 40 | ``` 41 | Login to central.sonatype.com 42 | Check https://central.sonatype.com/publishing/deployments 43 | 44 | Wait for 24 hrs for publishing to complete 45 | 46 | ``` -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/OutputStreams.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import org.scalajs.jsenv.RunConfig 4 | 5 | import java.io._ 6 | 7 | object OutputStreams { 8 | final class Streams(val out: PrintStream, val err: PrintStream) { 9 | def close(): Unit = { 10 | out.close() 11 | err.close() 12 | } 13 | } 14 | 15 | def prepare(config: RunConfig): Streams = { 16 | val outp = optPipe(!config.inheritOutput) 17 | val errp = optPipe(!config.inheritError) 18 | 19 | config.onOutputStream.foreach(f => f(outp.map(_._1), errp.map(_._1))) 20 | 21 | val out = outp.fold[OutputStream](new UnownedOutputStream(System.out))(_._2) 22 | val err = errp.fold[OutputStream](new UnownedOutputStream(System.err))(_._2) 23 | 24 | new Streams(new PrintStream(out), new PrintStream(err)) 25 | } 26 | 27 | private def optPipe(want: Boolean) = { 28 | if (want) { 29 | val i = new PipedInputStream() 30 | val o = new PipedOutputStream(i) 31 | Some((i, o)) 32 | } else { 33 | None 34 | } 35 | } 36 | 37 | private class UnownedOutputStream(out: OutputStream) extends FilterOutputStream(out) { 38 | override def close(): Unit = flush() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Scala CI 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | permissions: 15 | contents: write 16 | packages: write 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up JDK 11 24 | uses: actions/setup-java@v4 25 | with: 26 | java-version: '17' 27 | distribution: 'temurin' 28 | cache: 'sbt' 29 | - name: Install sbt 30 | uses: sbt/setup-sbt@v1 31 | - name: Cache Playwright drivers 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.cache/ms-playwright/ 35 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/pom.xml') }} 36 | restore-keys: | 37 | ${{ runner.os }}-playwright- 38 | - name: Run tests 39 | run: sbt test 40 | - name: Update Dependency Graph 41 | if: github.ref == 'refs/heads/main' 42 | uses: scalacenter/sbt-dependency-submission@v3 -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.17 2 | 3 | runner.dialect = scala3 4 | 5 | maxColumn = 96 6 | 7 | includeCurlyBraceInSelectChains = true 8 | includeNoParensInSelectChains = true 9 | 10 | optIn { 11 | breakChainOnFirstMethodDot = false 12 | forceBlankLineBeforeDocstring = true 13 | } 14 | 15 | binPack { 16 | literalArgumentLists = true 17 | parentConstructors = Never 18 | } 19 | 20 | danglingParentheses { 21 | defnSite = false 22 | callSite = false 23 | ctrlSite = false 24 | 25 | exclude = [] 26 | } 27 | 28 | newlines { 29 | beforeCurlyLambdaParams = multilineWithCaseOnly 30 | afterCurlyLambda = squash 31 | implicitParamListModifierPrefer = before 32 | sometimesBeforeColonInMethodReturnType = true 33 | } 34 | 35 | align.preset = none 36 | align.stripMargin = true 37 | 38 | assumeStandardLibraryStripMargin = true 39 | 40 | docstrings { 41 | style = Asterisk 42 | oneline = unfold 43 | } 44 | 45 | project.git = true 46 | 47 | trailingCommas = never 48 | 49 | rewrite { 50 | // RedundantBraces honestly just doesn't work, otherwise I'd love to use it 51 | rules = [PreferCurlyFors, RedundantParens, SortImports] 52 | 53 | redundantBraces { 54 | maxLines = 1 55 | stringInterpolation = true 56 | } 57 | } 58 | 59 | rewriteTokens { 60 | "⇒": "=>" 61 | "→": "->" 62 | "←": "<-" 63 | } 64 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/CEComRun.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import jsenv.playwright.PWEnv.Config 6 | import org.scalajs.jsenv.Input 7 | import org.scalajs.jsenv.JSComRun 8 | import org.scalajs.jsenv.RunConfig 9 | 10 | import scala.concurrent._ 11 | 12 | // browserName, headless, pwConfig, runConfig, input, onMessage 13 | class CEComRun( 14 | override val browserName: String, 15 | override val headless: Boolean, 16 | override val pwConfig: Config, 17 | override val runConfig: RunConfig, 18 | override val input: Seq[Input], 19 | override val launchOptions: List[String], 20 | override val additionalLaunchOptions: List[String], 21 | onMessage: String => Unit 22 | ) extends JSComRun 23 | with Runner { 24 | scribe.debug(s"Creating CEComRun for $browserName") 25 | // enableCom is false for CERun and true for CEComRun 26 | // send is called only from JSComRun 27 | override def send(msg: String): Unit = sendQueue.offer(msg) 28 | // receivedMessage is called only from JSComRun. Hence its implementation is empty in CERun 29 | override protected def receivedMessage(msg: String): Unit = onMessage(msg) 30 | 31 | lazy val future: Future[Unit] = 32 | jsRunPrg(browserName, headless, isComEnabled = true, pwLaunchOptions) 33 | .use(_ => IO.unit) 34 | .unsafeToFuture() 35 | 36 | } 37 | 38 | private class WindowOnErrorException(errs: List[String]) extends Exception(s"JS error: $errs") 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, gmkumar2005 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/CEUtils.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import org.scalajs.jsenv.Input 4 | import org.scalajs.jsenv.UnsupportedInputException 5 | import scribe.format.FormatterInterpolator 6 | import scribe.format.classNameSimple 7 | import scribe.format.dateFull 8 | import scribe.format.level 9 | import scribe.format.mdc 10 | import scribe.format.messages 11 | import scribe.format.methodName 12 | import scribe.format.threadName 13 | 14 | import java.nio.file.Path 15 | 16 | object CEUtils { 17 | def htmlPage( 18 | fullInput: Seq[Input], 19 | materializer: FileMaterializer 20 | ): String = { 21 | val tags = fullInput.map { 22 | case Input.Script(path) => makeTag(path, "text/javascript", materializer) 23 | case Input.CommonJSModule(path) => 24 | makeTag(path, "text/javascript", materializer) 25 | case Input.ESModule(path) => makeTag(path, "module", materializer) 26 | case _ => throw new UnsupportedInputException(fullInput) 27 | } 28 | 29 | s""" 30 | | 31 | | 32 | | ${tags.mkString("\n ")} 33 | | 34 | | 35 | """.stripMargin 36 | } 37 | 38 | private def makeTag( 39 | path: Path, 40 | tpe: String, 41 | materializer: FileMaterializer 42 | ): String = { 43 | val url = materializer.materialize(path) 44 | s"" 45 | } 46 | 47 | def setupLogger(showLogs: Boolean, debug: Boolean): Unit = { 48 | val formatter = 49 | formatter"$dateFull [$threadName] $classNameSimple $level $methodName - $messages$mdc" 50 | scribe 51 | .Logger 52 | .root 53 | .clearHandlers() 54 | .withHandler( 55 | formatter = formatter 56 | ) 57 | .replace() 58 | // default log level is error 59 | scribe.Logger.root.withMinimumLevel(scribe.Level.Error).replace() 60 | 61 | if (showLogs) { 62 | scribe.Logger.root.withMinimumLevel(scribe.Level.Info).replace() 63 | } 64 | if (debug) { 65 | scribe.Logger.root.withMinimumLevel(scribe.Level.Trace).replace() 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/simple.js: -------------------------------------------------------------------------------- 1 | 2 | (function() { 3 | // Buffers for console.log / console.error 4 | var consoleLog = []; 5 | var consoleError = []; 6 | 7 | // Buffer for errors. 8 | var errors = []; 9 | 10 | // Buffer for outgoing messages. 11 | var outMessages = []; 12 | 13 | // Buffer for incoming messages (used if onMessage not initalized). 14 | var inMessages = []; 15 | 16 | // Callback for incoming messages. 17 | var onMessage = null; 18 | 19 | function captureConsole(fun, buf) { 20 | if (!fun) return fun; 21 | return function() { 22 | var strs = [] 23 | for (var i = 0; i < arguments.length; ++i) 24 | strs.push(String(arguments[i])); 25 | 26 | buf.push(strs.join(" ")); 27 | return fun.apply(this, arguments); 28 | } 29 | } 30 | 31 | console.log = captureConsole(console.log, consoleLog); 32 | console.error = captureConsole(console.error, consoleError); 33 | 34 | window.addEventListener('error', function(e) { 35 | errors.push(e.message) 36 | }); 37 | 38 | if (true) { 39 | this.scalajsCom = { 40 | init: function(onMsg) { 41 | onMessage = onMsg; 42 | window.setTimeout(function() { 43 | for (var m in inMessages) 44 | onMessage(inMessages[m]); 45 | inMessages = null; 46 | }); 47 | }, 48 | send: function(msg) { outMessages.push(msg); } 49 | } 50 | } 51 | 52 | this.scalajsPlayWrightInternalInterface = { 53 | fetch: function() { 54 | var res = { 55 | consoleLog: consoleLog.slice(), 56 | consoleError: consoleError.slice(), 57 | errors: errors.slice(), 58 | msgs: outMessages.slice() 59 | } 60 | 61 | consoleLog.length = 0; 62 | consoleError.length = 0; 63 | errors.length = 0; 64 | outMessages.length = 0; 65 | 66 | return res; 67 | }, 68 | send: function(msg) { 69 | if (inMessages !== null) inMessages.push(msg); 70 | else onMessage(msg); 71 | } 72 | }; 73 | }).call(this) 74 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/PageFactory.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import cats.effect.IO 4 | import cats.effect.Resource 5 | import com.microsoft.playwright.Browser 6 | import com.microsoft.playwright.BrowserType 7 | import com.microsoft.playwright.BrowserType.LaunchOptions 8 | import com.microsoft.playwright.Page 9 | import com.microsoft.playwright.Playwright 10 | 11 | object PageFactory { 12 | def pageBuilder(browser: Browser): Resource[IO, Page] = { 13 | Resource.make(IO { 14 | val pg = browser.newContext().newPage() 15 | scribe.debug(s"Creating page ${pg.hashCode()} ") 16 | pg 17 | })(page => IO { page.close() }) 18 | } 19 | 20 | private def browserBuilder( 21 | playwright: Playwright, 22 | browserName: String, 23 | headless: Boolean, 24 | launchOptions: LaunchOptions 25 | ): Resource[IO, Browser] = 26 | Resource.make(IO { 27 | 28 | val browserType: BrowserType = browserName.toLowerCase match { 29 | case "chromium" | "chrome" => 30 | playwright.chromium() 31 | case "firefox" => 32 | playwright.firefox() 33 | case "webkit" => 34 | playwright.webkit() 35 | case _ => throw new IllegalArgumentException("Invalid browser type") 36 | } 37 | val browser = browserType.launch(launchOptions.setHeadless(headless)) 38 | scribe.info( 39 | s"Creating browser ${browser.browserType().name()} version ${browser.version()} with ${browser.hashCode()}" 40 | ) 41 | browser 42 | })(browser => 43 | IO { 44 | scribe.debug(s"Closing browser with ${browser.hashCode()}") 45 | browser.close() 46 | }) 47 | 48 | private def playWrightBuilder: Resource[IO, Playwright] = 49 | Resource.make(IO { 50 | scribe.debug(s"Creating playwright") 51 | Playwright.create() 52 | })(pw => 53 | IO { 54 | scribe.debug("Closing playwright") 55 | pw.close() 56 | }) 57 | 58 | def createPage( 59 | browserName: String, 60 | headless: Boolean, 61 | launchOptions: LaunchOptions 62 | ): Resource[IO, Page] = 63 | for { 64 | playwright <- playWrightBuilder 65 | browser <- browserBuilder( 66 | playwright, 67 | browserName, 68 | headless, 69 | launchOptions 70 | ) 71 | page <- pageBuilder(browser) 72 | } yield page 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/FileMaterializers.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import java.net._ 4 | import java.nio.file._ 5 | import java.util 6 | 7 | abstract class FileMaterializer extends AutoCloseable { 8 | private val tmpSuffixRE = """[a-zA-Z0-9-_.]*$""".r 9 | 10 | private var tmpFiles: List[Path] = Nil 11 | 12 | def materialize(path: Path): URL = { 13 | val tmp = newTmp(path.toString) 14 | // if file with extension .map exist then copy it too 15 | val mapPath = Paths.get(path.toString + ".map") 16 | Files.copy(path, tmp, StandardCopyOption.REPLACE_EXISTING) 17 | if (Files.exists(mapPath)) { 18 | val tmpMap = newTmp(mapPath.toString) 19 | Files.copy(mapPath, tmpMap, StandardCopyOption.REPLACE_EXISTING) 20 | } 21 | toURL(tmp) 22 | } 23 | 24 | final def materialize(name: String, content: String): URL = { 25 | val tmp = newTmp(name) 26 | Files.write(tmp, util.Arrays.asList(content)) 27 | toURL(tmp) 28 | } 29 | 30 | final def close(): Unit = { 31 | tmpFiles.foreach(Files.delete) 32 | tmpFiles = Nil 33 | } 34 | 35 | private def newTmp(path: String): Path = { 36 | val suffix = tmpSuffixRE.findFirstIn(path).orNull 37 | val p = createTmp(suffix) 38 | tmpFiles ::= p 39 | p 40 | } 41 | 42 | protected def createTmp(suffix: String): Path 43 | protected def toURL(file: Path): URL 44 | } 45 | 46 | object FileMaterializer { 47 | import PWEnv.Config.Materialization 48 | def apply(m: Materialization): FileMaterializer = m match { 49 | case Materialization.Temp => 50 | new TempDirFileMaterializer 51 | 52 | case Materialization.Server(contentDir, webRoot) => 53 | new ServerDirFileMaterializer(contentDir, webRoot) 54 | } 55 | } 56 | 57 | /** 58 | * materializes virtual files in a temp directory (uses file:// schema). 59 | */ 60 | private class TempDirFileMaterializer extends FileMaterializer { 61 | override def materialize(path: Path): URL = { 62 | try { 63 | path.toFile.toURI.toURL 64 | } catch { 65 | case _: UnsupportedOperationException => 66 | super.materialize(path) 67 | } 68 | } 69 | 70 | protected def createTmp(suffix: String): Path = 71 | Files.createTempFile(null, suffix) 72 | protected def toURL(file: Path): URL = file.toUri.toURL 73 | } 74 | 75 | private class ServerDirFileMaterializer(contentDir: Path, webRoot: URL) 76 | extends FileMaterializer { 77 | Files.createDirectories(contentDir) 78 | 79 | protected def createTmp(suffix: String): Path = 80 | Files.createTempFile(contentDir, null, suffix) 81 | 82 | protected def toURL(file: Path): URL = { 83 | val rel = contentDir.relativize(file) 84 | assert(!rel.isAbsolute) 85 | val nameURI = new URI(null, null, rel.toString, null) 86 | webRoot.toURI.resolve(nameURI).toURL 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/JSSetup.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import com.google.common.jimfs.Jimfs 4 | 5 | import java.nio.charset.StandardCharsets 6 | import java.nio.file.Files 7 | import java.nio.file.Path 8 | 9 | private object JSSetup { 10 | def setupFile(enableCom: Boolean): Path = { 11 | val path = Jimfs.newFileSystem().getPath("setup.js") 12 | val contents = setupCode(enableCom).getBytes(StandardCharsets.UTF_8) 13 | Files.write(path, contents) 14 | } 15 | 16 | private def setupCode(enableCom: Boolean): String = { 17 | s""" 18 | |(function() { 19 | | // Buffers for console.log / console.error 20 | | var consoleLog = []; 21 | | var consoleError = []; 22 | | 23 | | // Buffer for errors. 24 | | var errors = []; 25 | | 26 | | // Buffer for outgoing messages. 27 | | var outMessages = []; 28 | | 29 | | // Buffer for incoming messages (used if onMessage not initalized). 30 | | var inMessages = []; 31 | | 32 | | // Callback for incoming messages. 33 | | var onMessage = null; 34 | | 35 | | function captureConsole(fun, buf) { 36 | | if (!fun) return fun; 37 | | return function() { 38 | | var strs = [] 39 | | for (var i = 0; i < arguments.length; ++i) 40 | | strs.push(String(arguments[i])); 41 | | 42 | | buf.push(strs.join(" ")); 43 | | return fun.apply(this, arguments); 44 | | } 45 | | } 46 | | 47 | | console.log = captureConsole(console.log, consoleLog); 48 | | console.error = captureConsole(console.error, consoleError); 49 | | 50 | | window.addEventListener('error', function(e) { 51 | | errors.push(e.message) 52 | | }); 53 | | 54 | | if ($enableCom) { 55 | | this.scalajsCom = { 56 | | init: function(onMsg) { 57 | | onMessage = onMsg; 58 | | window.setTimeout(function() { 59 | | for (var m in inMessages) 60 | | onMessage(inMessages[m]); 61 | | inMessages = null; 62 | | }); 63 | | }, 64 | | send: function(msg) { outMessages.push(msg); } 65 | | } 66 | | } 67 | | 68 | | this.scalajsPlayWrightInternalInterface = { 69 | | fetch: function() { 70 | | var res = { 71 | | consoleLog: consoleLog.slice(), 72 | | consoleError: consoleError.slice(), 73 | | errors: errors.slice(), 74 | | msgs: outMessages.slice() 75 | | } 76 | | 77 | | consoleLog.length = 0; 78 | | consoleError.length = 0; 79 | | errors.length = 0; 80 | | outMessages.length = 0; 81 | | 82 | | return res; 83 | | }, 84 | | send: function(msg) { 85 | | if (inMessages !== null) inMessages.push(msg); 86 | | else onMessage(msg); 87 | | } 88 | | }; 89 | |}).call(this) 90 | """.stripMargin 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/test/scala/jsenv/playwright/RunTests.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import com.google.common.jimfs.Jimfs 4 | import org.junit.Test 5 | import org.scalajs.jsenv._ 6 | import org.scalajs.jsenv.test.kit.Run 7 | import org.scalajs.jsenv.test.kit.TestKit 8 | 9 | import java.io.File 10 | import java.nio.charset.StandardCharsets 11 | import java.nio.file.Files 12 | import scala.concurrent.duration.DurationInt 13 | 14 | class RunTests { 15 | val withCom = true 16 | private val kit = new TestKit(new PWEnv("chrome", debug = true), 100.second) 17 | 18 | private def withRun(input: Seq[Input])(body: Run => Unit): Unit = { 19 | if (withCom) kit.withComRun(input)(body) 20 | else kit.withRun(input)(body) 21 | } 22 | 23 | private def withRun(code: String, config: RunConfig = RunConfig())( 24 | body: Run => Unit 25 | ): Unit = { 26 | if (withCom) kit.withComRun(code, config)(body) 27 | else kit.withRun(code, config)(body) 28 | } 29 | 30 | @Test 31 | def failureTest(): Unit = { 32 | withRun(""" 33 | var a = {}; 34 | a.foo(); 35 | """) { 36 | _.fails() 37 | } 38 | } 39 | 40 | @Test 41 | def syntaxErrorTest(): Unit = { 42 | withRun("{") { 43 | _.fails() 44 | } 45 | } 46 | 47 | @Test 48 | def throwExceptionTest(): Unit = { 49 | withRun("throw 1;") { 50 | _.fails() 51 | } 52 | } 53 | 54 | @Test 55 | def catchExceptionTest(): Unit = { 56 | withRun(""" 57 | try { 58 | throw "hello world"; 59 | } catch (e) { 60 | console.log(e); 61 | } 62 | """) { 63 | _.expectOut("hello world\n").closeRun() 64 | } 65 | } 66 | 67 | @Test // Failed in Phantom - #2053 68 | def utf8Test(): Unit = { 69 | withRun("console.log('\u1234')") { 70 | _.expectOut("\u1234\n").closeRun() 71 | } 72 | } 73 | 74 | @Test 75 | def allowScriptTags(): Unit = { 76 | withRun("""console.log("");""") { 77 | _.expectOut("\n").closeRun() 78 | } 79 | } 80 | 81 | // @Test 82 | // def jsExitsTest: Unit = { 83 | // val exitStat = config.exitJSStatement.getOrElse( 84 | // throw new AssumptionViolatedException("JSEnv needs exitJSStatement")) 85 | // 86 | // withRun(exitStat) { 87 | // _.succeeds() 88 | // } 89 | // } 90 | 91 | // #500 Node.js used to strip double percentage signs even with only 1 argument 92 | @Test 93 | def percentageTest(): Unit = { 94 | val strings = (1 to 15).map("%" * _) 95 | val code = strings.map(str => s"""console.log("$str");\n""").mkString("") 96 | val result = strings.mkString("", "\n", "\n") 97 | 98 | withRun(code) { 99 | _.expectOut(result).closeRun() 100 | } 101 | } 102 | 103 | @Test 104 | def fastCloseTest(): Unit = { 105 | /* This test also tests a failure mode where the ExternalJSRun is still 106 | * piping output while the client calls close. 107 | */ 108 | withRun("") { 109 | _.closeRun() 110 | } 111 | } 112 | 113 | @Test 114 | def multiCloseAfterTerminatedTest(): Unit = { 115 | withRun("") { run => 116 | run.closeRun() 117 | 118 | // Should be noops (and not fail). 119 | run.closeRun() 120 | run.closeRun() 121 | run.closeRun() 122 | } 123 | } 124 | 125 | @Test 126 | def noThrowOnBadFileTest(): Unit = { 127 | val badFile = Jimfs.newFileSystem().getPath("nonexistent") 128 | 129 | // `start` may not throw but must fail asynchronously 130 | withRun(Input.Script(badFile) :: Nil) { 131 | _.fails() 132 | } 133 | } 134 | 135 | @Test 136 | def defaultFilesystem(): Unit = { 137 | // Tests that a JSEnv works with files from the default filesystem. 138 | 139 | val tmpFile = File.createTempFile("sjs-run-test-defaultfile", ".js") 140 | try { 141 | val tmpPath = tmpFile.toPath 142 | Files.write( 143 | tmpPath, 144 | "console.log(\"test\");".getBytes(StandardCharsets.UTF_8) 145 | ) 146 | 147 | withRun(Input.Script(tmpPath) :: Nil) { 148 | _.expectOut("test\n").closeRun() 149 | } 150 | } finally { 151 | tmpFile.delete() 152 | } 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scala CI](https://github.com/gmkumar2005/scala-js-env-playwright/actions/workflows/scala.yml/badge.svg)](https://github.com/gmkumar2005/scala-js-env-playwright/actions/workflows/scala.yml) 2 | # scala-js-env-playwright 3 | A JavaScript environment for Scala.js (a JSEnv) running playwright 4 | ## Usage 5 | Add the following line to your `project/plugins.sbt` 6 | ```scala 7 | // For Scala.js 1.x 8 | libraryDependencies += "io.github.gmkumar2005" %% "scala-js-env-playwright" % "0.1.11" 9 | ``` 10 | Add the following line to your `build.sbt` 11 | ```scala 12 | Test / jsEnv := new PWEnv( 13 | browserName = "chrome", 14 | headless = true, 15 | showLogs = true 16 | ) 17 | ``` 18 | ## Avoid trouble 19 | * This is a very early version. It may not work for all projects. It is tested on chrome/chromium and firefox. 20 | * Few test cases are failing on webkit. Keep a watch on this space for updates. 21 | * It works only with Scala.js 1.x 22 | * Make sure the project is set up to use ModuleKind.ESModule in the Scala.js project. 23 | ```scala 24 | // For Scala.js 1.x 25 | scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) } 26 | ``` 27 | * Some projects which may need to use both Selenium and Playwright. 28 | If it runs into google exception, add the following line to your `plugins.sbt` 29 | ```scala 30 | libraryDependencies += "com.google.guava" % "guava" % "33.0.0-jre" 31 | ``` 32 | 33 | ## Supported browsers 34 | * chrome 35 | * chromium (same as chrome) 36 | * firefox 37 | * webkit (experimental) - Works will on macOS. Mileage may vary on other platforms. 38 | 39 | ## Compatibility notes 40 | ### Scala versions 41 | * This library can be used with any scala version 2.x and 3.x 42 | * This project is compiled with scala 2.12.20 43 | ### sbt versions 44 | * This library can be used with any sbt version 1.x 45 | ### Playwright versions 46 | * This library can be used with playwright version 1.49.0 `"com.microsoft.playwright" % "playwright" % "1.49.0"` 47 | ### JDK versions 48 | * This library is tested on JDK21 49 | 50 | ## Default configuration 51 | ```scala 52 | jsEnv := new jsenv.playwright.PWEnv( 53 | browserName = "chrome", 54 | headless = true, 55 | showLogs = false, 56 | ) 57 | ``` 58 | 59 | ## Launch options 60 | 61 | The launch options are used to enable/disable the browser features. They can be overridden via the launchOptions parameter or extended via the additionalLaunchOptions parameter. 62 | 63 | When not passing launchOptions, default launch options are as follows: 64 | 65 | ### Chrome/chromium 66 | ```scala 67 | jsEnv := new jsenv.playwright.PWEnv( 68 | browserName = "chrome", 69 | launchOptions = List( 70 | "--disable-extensions", 71 | "--disable-web-security", 72 | "--allow-running-insecure-content", 73 | "--disable-site-isolation-trials", 74 | "--allow-file-access-from-files", 75 | "--disable-gpu" 76 | ) 77 | ) 78 | ``` 79 | 80 | ### Firefox 81 | ```scala 82 | jsEnv := new jsenv.playwright.PWEnv( 83 | browserName = "firefox", 84 | launchOptions = List( 85 | "--disable-web-security" 86 | ) 87 | ) 88 | ``` 89 | 90 | ### Webkit 91 | ```scala 92 | jsEnv := new jsenv.playwright.PWEnv( 93 | browserName = "webkit", 94 | launchOptions = List( 95 | "--disable-extensions", 96 | "--disable-web-security", 97 | "--allow-running-insecure-content", 98 | "--disable-site-isolation-trials", 99 | "--allow-file-access-from-files" 100 | ) 101 | ) 102 | ``` 103 | 104 | ## KeepAlive configuration 105 | It is work in progress. 106 | As a workaround introducing delay in the test cases may help to keep the browser alive. 107 | 108 | ## Debugging 109 | debug parameter can be passed to the PWEnv constructor to enable debugging. It will also display the version of the browser which is used. 110 | ```scala 111 | Test / jsEnv := new PWEnv( 112 | browserName = "chrome", 113 | headless = true, 114 | showLogs = true, 115 | debug = true 116 | ) 117 | ``` 118 | 119 | ## Wiki 120 | Watch this space for more details on how to use this library. 121 | 122 | ## References 123 | * Sample project using this JSEnv: https://github.com/gmkumar2005/scalajs-sbt-vite-laminar-chartjs-example 124 | * Fork of Laminar: https://github.com/gmkumar2005/Laminar 125 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/ResourcesFactory.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import cats.effect.IO 4 | import cats.effect.Resource 5 | import com.microsoft.playwright.Page 6 | import jsenv.playwright.PWEnv.Config 7 | import org.scalajs.jsenv.Input 8 | import org.scalajs.jsenv.RunConfig 9 | 10 | import java.util 11 | import java.util.concurrent.ConcurrentLinkedQueue 12 | import java.util.concurrent.atomic.AtomicBoolean 13 | import java.util.function.Consumer 14 | import scala.annotation.tailrec 15 | import scala.concurrent.duration.DurationInt 16 | 17 | object ResourcesFactory { 18 | def preparePageForJsRun( 19 | pageInstance: Page, 20 | materializerResource: Resource[IO, FileMaterializer], 21 | input: Seq[Input], 22 | enableCom: Boolean 23 | ): Resource[IO, Unit] = 24 | for { 25 | m <- materializerResource 26 | _ <- Resource.pure( 27 | scribe.debug(s"Page instance is ${pageInstance.hashCode()}") 28 | ) 29 | _ <- Resource.pure { 30 | val setupJsScript = Input.Script(JSSetup.setupFile(enableCom)) 31 | val fullInput = setupJsScript +: input 32 | val materialPage = 33 | m.materialize( 34 | "scalajsRun.html", 35 | CEUtils.htmlPage(fullInput, m) 36 | ) 37 | pageInstance.navigate(materialPage.toString) 38 | } 39 | } yield () 40 | 41 | private def fetchMessages( 42 | pageInstance: Page, 43 | intf: String 44 | ): java.util.Map[String, java.util.List[String]] = { 45 | val data = 46 | pageInstance 47 | .evaluate(s"$intf.fetch();") 48 | .asInstanceOf[java.util.Map[String, java.util.List[String]]] 49 | data 50 | } 51 | 52 | def processUntilStop( 53 | stopSignal: AtomicBoolean, 54 | pageInstance: Page, 55 | intf: String, 56 | sendQueue: ConcurrentLinkedQueue[String], 57 | outStream: OutputStreams.Streams, 58 | receivedMessage: String => Unit 59 | ): Resource[IO, Unit] = { 60 | Resource.pure[IO, Unit] { 61 | scribe.debug(s"Started processUntilStop") 62 | while (!stopSignal.get()) { 63 | sendAll(sendQueue, pageInstance, intf) 64 | val jsResponse = fetchMessages(pageInstance, intf) 65 | streamWriter(jsResponse, outStream, Some(receivedMessage)) 66 | IO.sleep(100.milliseconds) 67 | } 68 | scribe.debug(s"Stop processUntilStop") 69 | } 70 | } 71 | 72 | def isConnectionUp( 73 | pageInstance: Page, 74 | intf: String 75 | ): Resource[IO, Boolean] = { 76 | Resource.pure[IO, Boolean] { 77 | val status = pageInstance.evaluate(s"!!$intf;").asInstanceOf[Boolean] 78 | scribe.debug( 79 | s"Page instance is ${pageInstance.hashCode()} with status $status" 80 | ) 81 | status 82 | } 83 | 84 | } 85 | 86 | def materializer(pwConfig: Config): Resource[IO, FileMaterializer] = 87 | Resource.make { 88 | IO.blocking(FileMaterializer(pwConfig.materialization)) // build 89 | } { fileMaterializer => 90 | IO { 91 | scribe.debug("Closing the fileMaterializer") 92 | fileMaterializer.close() 93 | }.handleErrorWith(_ => { 94 | scribe.error("Error in closing the fileMaterializer") 95 | IO.unit 96 | }) // release 97 | } 98 | 99 | /* 100 | * Creates resource for outputStream 101 | */ 102 | def outputStream( 103 | runConfig: RunConfig 104 | ): Resource[IO, OutputStreams.Streams] = 105 | Resource.make { 106 | IO.blocking(OutputStreams.prepare(runConfig)) // build 107 | } { outStream => 108 | IO { 109 | scribe.debug(s"Closing the stream ${outStream.hashCode()}") 110 | outStream.close() 111 | }.handleErrorWith(_ => { 112 | scribe.error(s"Error in closing the stream ${outStream.hashCode()})") 113 | IO.unit 114 | }) // release 115 | } 116 | 117 | private def streamWriter( 118 | jsResponse: util.Map[String, util.List[String]], 119 | outStream: OutputStreams.Streams, 120 | onMessage: Option[String => Unit] = None 121 | ): Unit = { 122 | val data = jsResponse.get("consoleLog") 123 | val consoleError = jsResponse.get("consoleError") 124 | val error = jsResponse.get("errors") 125 | onMessage match { 126 | case Some(f) => 127 | val msgs = jsResponse.get("msgs") 128 | msgs.forEach(consumer(f)) 129 | case None => scribe.debug("No onMessage function") 130 | } 131 | data.forEach(outStream.out.println _) 132 | error.forEach(outStream.out.println _) 133 | consoleError.forEach(outStream.out.println _) 134 | 135 | if (!error.isEmpty) { 136 | val errList = error.toArray(Array[String]()).toList 137 | throw new WindowOnErrorException(errList) 138 | } 139 | } 140 | 141 | @tailrec 142 | def sendAll( 143 | sendQueue: ConcurrentLinkedQueue[String], 144 | pageInstance: Page, 145 | intf: String 146 | ): Unit = { 147 | val msg = sendQueue.poll() 148 | if (msg != null) { 149 | scribe.debug(s"Sending message") 150 | val script = s"$intf.send(arguments[0]);" 151 | val wrapper = s"function(arg) { $script }" 152 | pageInstance.evaluate(s"$wrapper", msg) 153 | val pwDebug = sys.env.getOrElse("PWDEBUG", "0") 154 | if (pwDebug == "1") { 155 | pageInstance.pause() 156 | } 157 | sendAll(sendQueue, pageInstance, intf) 158 | } 159 | } 160 | private def consumer[A](f: A => Unit): Consumer[A] = (v: A) => f(v) 161 | private def logStackTrace(): Unit = { 162 | try { 163 | throw new Exception("Logging stack trace") 164 | } catch { 165 | case e: Exception => e.printStackTrace() 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/Runner.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import cats.effect.IO 4 | import cats.effect.Resource 5 | import com.microsoft.playwright.BrowserType 6 | import com.microsoft.playwright.BrowserType.LaunchOptions 7 | import jsenv.playwright.PWEnv.Config 8 | import jsenv.playwright.PageFactory._ 9 | import jsenv.playwright.ResourcesFactory._ 10 | import org.scalajs.jsenv.Input 11 | import org.scalajs.jsenv.RunConfig 12 | 13 | import java.util.concurrent.ConcurrentLinkedQueue 14 | import java.util.concurrent.atomic.AtomicBoolean 15 | import scala.concurrent.duration.DurationInt 16 | import scala.jdk.CollectionConverters.seqAsJavaListConverter 17 | 18 | trait Runner { 19 | val browserName: String = "" // or provide actual values 20 | val headless: Boolean = false // or provide actual values 21 | val pwConfig: Config = Config() // or provide actual values 22 | val runConfig: RunConfig = RunConfig() // or provide actual values 23 | val input: Seq[Input] = Seq.empty // or provide actual values 24 | val launchOptions: List[String] = Nil 25 | val additionalLaunchOptions: List[String] = Nil 26 | 27 | // enableCom is false for CERun and true for CEComRun 28 | protected val enableCom = false 29 | protected val intf = "this.scalajsPlayWrightInternalInterface" 30 | protected val sendQueue = new ConcurrentLinkedQueue[String] 31 | // receivedMessage is called only from JSComRun. Hence its implementation is empty in CERun 32 | protected def receivedMessage(msg: String): Unit 33 | var wantToClose = new AtomicBoolean(false) 34 | // List of programs 35 | // 1. isInterfaceUp() 36 | // Create PW resource if not created. Create browser,context and page 37 | // 2. Sleep 38 | // 3. wantClose 39 | // 4. sendAll() 40 | // 5. fetchAndProcess() 41 | // 6. Close diver 42 | // 7. Close streams 43 | // 8. Close materializer 44 | // Flow 45 | // if interface is down and dont want to close wait for 100 milliseconds 46 | // interface is up and dont want to close sendAll(), fetchAndProcess() Sleep for 100 milliseconds 47 | // If want to close then close driver, streams, materializer 48 | // After future is completed close driver, streams, materializer 49 | 50 | def jsRunPrg( 51 | browserName: String, 52 | headless: Boolean, 53 | isComEnabled: Boolean, 54 | launchOptions: LaunchOptions 55 | ): Resource[IO, Unit] = for { 56 | _ <- Resource.pure( 57 | scribe.info( 58 | s"Begin Main with isComEnabled $isComEnabled " + 59 | s"and browserName $browserName " + 60 | s"and headless is $headless " 61 | ) 62 | ) 63 | pageInstance <- createPage( 64 | browserName, 65 | headless, 66 | launchOptions 67 | ) 68 | _ <- preparePageForJsRun( 69 | pageInstance, 70 | materializer(pwConfig), 71 | input, 72 | isComEnabled 73 | ) 74 | connectionReady <- isConnectionUp(pageInstance, intf) 75 | _ <- 76 | if (!connectionReady) Resource.pure[IO, Unit] { 77 | IO.sleep(100.milliseconds) 78 | } 79 | else Resource.pure[IO, Unit](IO.unit) 80 | _ <- 81 | if (!connectionReady) isConnectionUp(pageInstance, intf) 82 | else Resource.pure[IO, Unit](IO.unit) 83 | out <- outputStream(runConfig) 84 | _ <- processUntilStop( 85 | wantToClose, 86 | pageInstance, 87 | intf, 88 | sendQueue, 89 | out, 90 | receivedMessage 91 | ) 92 | } yield () 93 | 94 | /** 95 | * Stops the run and releases all the resources. 96 | * 97 | * This must be called to ensure the run's resources are released. 98 | * 99 | * Whether or not this makes the run fail or not is up to the implementation. However, in the 100 | * following cases, calling [[close]] may not fail the run: 103 | * 104 | * Idempotent, async, nothrow. 105 | */ 106 | 107 | def close(): Unit = { 108 | wantToClose.set(true) 109 | scribe.debug(s"Received stopSignal ${wantToClose.get()}") 110 | } 111 | 112 | def getCaller: String = { 113 | val stackTraceElements = Thread.currentThread().getStackTrace 114 | if (stackTraceElements.length > 5) { 115 | val callerElement = stackTraceElements(5) 116 | s"Caller class: ${callerElement.getClassName}, method: ${callerElement.getMethodName}" 117 | } else { 118 | "Could not determine caller." 119 | } 120 | } 121 | 122 | def logStackTrace(): Unit = { 123 | try { 124 | throw new Exception("Logging stack trace") 125 | } catch { 126 | case e: Exception => e.printStackTrace() 127 | } 128 | } 129 | 130 | protected lazy val pwLaunchOptions = 131 | browserName.toLowerCase() match { 132 | case "chromium" | "chrome" => 133 | new BrowserType.LaunchOptions().setArgs( 134 | if (launchOptions.isEmpty) 135 | (PWEnv.chromeLaunchOptions ++ additionalLaunchOptions).asJava 136 | else (launchOptions ++ additionalLaunchOptions).asJava 137 | ) 138 | case "firefox" => 139 | new BrowserType.LaunchOptions().setArgs( 140 | if (launchOptions.isEmpty) 141 | (PWEnv.firefoxLaunchOptions ++ additionalLaunchOptions).asJava 142 | else (launchOptions ++ additionalLaunchOptions).asJava 143 | ) 144 | case "webkit" => 145 | new BrowserType.LaunchOptions().setArgs( 146 | if (launchOptions.isEmpty) 147 | (PWEnv.webkitLaunchOptions ++ additionalLaunchOptions).asJava 148 | else (launchOptions ++ additionalLaunchOptions).asJava 149 | ) 150 | case _ => throw new IllegalArgumentException("Invalid browser type") 151 | } 152 | 153 | } 154 | 155 | //private class WindowOnErrorException(errs: List[String]) 156 | // extends Exception(s"JS error: $errs") 157 | -------------------------------------------------------------------------------- /src/main/scala/jsenv/playwright/PWEnv.scala: -------------------------------------------------------------------------------- 1 | package jsenv.playwright 2 | 3 | import jsenv.playwright.PWEnv.Config 4 | import org.scalajs.jsenv._ 5 | 6 | import java.net.URI 7 | import java.net.URL 8 | import java.nio.file.Path 9 | import java.nio.file.Paths 10 | import scala.util.control.NonFatal 11 | 12 | /** 13 | * Playwright JS environment 14 | * 15 | * @param browserName 16 | * browser name, options are "chromium", "chrome", "firefox", "webkit", default is "chromium" 17 | * @param headless 18 | * headless mode, default is true 19 | * @param showLogs 20 | * show logs, default is false 21 | * @param debug 22 | * debug mode, default is false 23 | * @param pwConfig 24 | * Playwright configuration 25 | * @param launchOptions 26 | * override launch options, if not provided default launch options are used 27 | * @param additionalLaunchOptions 28 | * additional launch options (added to (default) launch options) 29 | */ 30 | class PWEnv( 31 | browserName: String = "chromium", 32 | headless: Boolean = true, 33 | showLogs: Boolean = false, 34 | debug: Boolean = false, 35 | pwConfig: Config = Config(), 36 | launchOptions: List[String] = Nil, 37 | additionalLaunchOptions: List[String] = Nil 38 | ) extends JSEnv { 39 | 40 | private lazy val validator = { 41 | RunConfig.Validator().supportsInheritIO().supportsOnOutputStream() 42 | } 43 | override val name: String = s"CEEnv with $browserName" 44 | System.setProperty("playwright.driver.impl", "jsenv.DriverJar") 45 | CEUtils.setupLogger(showLogs, debug) 46 | 47 | override def start(input: Seq[Input], runConfig: RunConfig): JSRun = { 48 | try { 49 | validator.validate(runConfig) 50 | new CERun( 51 | browserName, 52 | headless, 53 | pwConfig, 54 | runConfig, 55 | input, 56 | launchOptions, 57 | additionalLaunchOptions) 58 | } catch { 59 | case ve: java.lang.IllegalArgumentException => 60 | scribe.error(s"CEEnv.startWithCom failed with throw ve $ve") 61 | throw ve 62 | case NonFatal(t) => 63 | scribe.error(s"CEEnv.start failed with $t") 64 | JSRun.failed(t) 65 | } 66 | } 67 | 68 | override def startWithCom( 69 | input: Seq[Input], 70 | runConfig: RunConfig, 71 | onMessage: String => Unit 72 | ): JSComRun = { 73 | try { 74 | validator.validate(runConfig) 75 | new CEComRun( 76 | browserName, 77 | headless, 78 | pwConfig, 79 | runConfig, 80 | input, 81 | launchOptions, 82 | additionalLaunchOptions, 83 | onMessage 84 | ) 85 | } catch { 86 | case ve: java.lang.IllegalArgumentException => 87 | scribe.error(s"CEEnv.startWithCom failed with throw ve $ve") 88 | throw ve 89 | case NonFatal(t) => 90 | scribe.error(s"CEEnv.startWithCom failed with $t") 91 | JSComRun.failed(t) 92 | } 93 | } 94 | 95 | } 96 | 97 | object PWEnv { 98 | case class Config( 99 | materialization: Config.Materialization = Config.Materialization.Temp 100 | ) { 101 | import Config.Materialization 102 | 103 | /** 104 | * Materializes purely virtual files into a temp directory. 105 | * 106 | * Materialization is necessary so that virtual files can be referred to by name. If you do 107 | * not know/care how your files are referred to, this is a good default choice. It is also 108 | * the default of [[PWEnv.Config]]. 109 | */ 110 | def withMaterializeInTemp: Config = 111 | copy(materialization = Materialization.Temp) 112 | 113 | /** 114 | * Materializes files in a static directory of a user configured server. 115 | * 116 | * This can be used to bypass cross origin access policies. 117 | * 118 | * @param contentDir 119 | * Static content directory of the server. The files will be put here. Will get created if 120 | * it doesn't exist. 121 | * @param webRoot 122 | * URL making `contentDir` accessible thorugh the server. This must have a trailing slash 123 | * to be interpreted as a directory. 124 | * 125 | * @example 126 | * 127 | * The following will make the browser fetch files using the http:// schema instead of the 128 | * file:// schema. The example assumes a local webserver is running and serving the ".tmp" 129 | * directory at http://localhost:8080. 130 | * 131 | * {{{ 132 | * jsSettings( 133 | * jsEnv := new SeleniumJSEnv( 134 | * new org.openqa.selenium.firefox.FirefoxOptions(), 135 | * SeleniumJSEnv.Config() 136 | * .withMaterializeInServer(".tmp", "http://localhost:8080/") 137 | * ) 138 | * ) 139 | * }}} 140 | */ 141 | def withMaterializeInServer(contentDir: String, webRoot: String): Config = 142 | withMaterializeInServer(Paths.get(contentDir), new URI(webRoot).toURL) 143 | 144 | /** 145 | * Materializes files in a static directory of a user configured server. 146 | * 147 | * Version of `withMaterializeInServer` with stronger typing. 148 | * 149 | * @param contentDir 150 | * Static content directory of the server. The files will be put here. Will get created if 151 | * it doesn't exist. 152 | * @param webRoot 153 | * URL making `contentDir` accessible thorugh the server. This must have a trailing slash 154 | * to be interpreted as a directory. 155 | */ 156 | def withMaterializeInServer(contentDir: Path, webRoot: URL): Config = 157 | copy(materialization = Materialization.Server(contentDir, webRoot)) 158 | 159 | def withMaterialization(materialization: Materialization): Config = 160 | copy(materialization = materialization) 161 | } 162 | 163 | object Config { 164 | 165 | abstract class Materialization private () 166 | object Materialization { 167 | final case object Temp extends Materialization 168 | final case class Server(contentDir: Path, webRoot: URL) extends Materialization { 169 | require( 170 | webRoot.getPath.endsWith("/"), 171 | "webRoot must end with a slash (/)" 172 | ) 173 | } 174 | } 175 | } 176 | 177 | val chromeLaunchOptions = List( 178 | "--disable-extensions", 179 | "--disable-web-security", 180 | "--allow-running-insecure-content", 181 | "--disable-site-isolation-trials", 182 | "--allow-file-access-from-files", 183 | "--disable-gpu" 184 | ) 185 | 186 | val firefoxLaunchOptions = List("--disable-web-security") 187 | 188 | val webkitLaunchOptions = List( 189 | "--disable-extensions", 190 | "--disable-web-security", 191 | "--allow-running-insecure-content", 192 | "--disable-site-isolation-trials", 193 | "--allow-file-access-from-files" 194 | ) 195 | } 196 | -------------------------------------------------------------------------------- /src/main/java/jsenv/DriverJar.java: -------------------------------------------------------------------------------- 1 | package jsenv; 2 | 3 | /* 4 | * Copyright (c) Microsoft Corporation. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | //package com.microsoft.playwright.impl.driver.jar; 20 | // String driverImpl = 21 | // System.getProperty("playwright.driver.impl", "com.microsoft.playwright.impl.driver.jar.DriverJar"); 22 | 23 | import com.microsoft.playwright.impl.driver.Driver; 24 | 25 | import java.io.IOException; 26 | import java.net.URI; 27 | import java.net.URISyntaxException; 28 | import java.nio.file.*; 29 | import java.util.Collections; 30 | import java.util.Map; 31 | import java.util.concurrent.TimeUnit; 32 | 33 | public class DriverJar extends Driver { 34 | private static final String PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"; 35 | private static final String SELENIUM_REMOTE_URL = "SELENIUM_REMOTE_URL"; 36 | private final Path driverTempDir; 37 | private Path preinstalledNodePath; 38 | 39 | public DriverJar() throws IOException { 40 | // Allow specifying custom path for the driver installation 41 | // See https://github.com/microsoft/playwright-java/issues/728 42 | String alternativeTmpdir = System.getProperty("playwright.driver.tmpdir"); 43 | String prefix = "playwright-java-"; 44 | driverTempDir = alternativeTmpdir == null 45 | ? Files.createTempDirectory(prefix) 46 | : Files.createTempDirectory(Paths.get(alternativeTmpdir), prefix); 47 | driverTempDir.toFile().deleteOnExit(); 48 | String nodePath = System.getProperty("playwright.nodejs.path"); 49 | if (nodePath != null) { 50 | preinstalledNodePath = Paths.get(nodePath); 51 | if (!Files.exists(preinstalledNodePath)) { 52 | throw new RuntimeException("Invalid Node.js path specified: " + nodePath); 53 | } 54 | } 55 | logMessage("created DriverJar: " + driverTempDir); 56 | } 57 | 58 | @Override 59 | protected void initialize(Boolean installBrowsers) throws Exception { 60 | if (preinstalledNodePath == null && env.containsKey(PLAYWRIGHT_NODEJS_PATH)) { 61 | preinstalledNodePath = Paths.get(env.get(PLAYWRIGHT_NODEJS_PATH)); 62 | if (!Files.exists(preinstalledNodePath)) { 63 | throw new RuntimeException("Invalid Node.js path specified: " + preinstalledNodePath); 64 | } 65 | } else if (preinstalledNodePath != null) { 66 | // Pass the env variable to the driver process. 67 | env.put(PLAYWRIGHT_NODEJS_PATH, preinstalledNodePath.toString()); 68 | } 69 | extractDriverToTempDir(); 70 | logMessage("extracted driver from jar to " + driverDir()); 71 | if (installBrowsers) 72 | installBrowsers(env); 73 | } 74 | 75 | private void installBrowsers(Map env) throws IOException, InterruptedException { 76 | String skip = env.get(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); 77 | if (skip == null) { 78 | skip = System.getenv(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); 79 | } 80 | if (skip != null && !"0".equals(skip) && !"false".equals(skip)) { 81 | logMessage("Skipping browsers download because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` env variable is set"); 82 | return; 83 | } 84 | if (env.get(SELENIUM_REMOTE_URL) != null || System.getenv(SELENIUM_REMOTE_URL) != null) { 85 | logMessage("Skipping browsers download because `SELENIUM_REMOTE_URL` env variable is set"); 86 | return; 87 | } 88 | Path driver = driverDir(); 89 | if (!Files.exists(driver)) { 90 | throw new RuntimeException("Failed to find driver: " + driver); 91 | } 92 | ProcessBuilder pb = createProcessBuilder(); 93 | pb.command().add("install"); 94 | pb.redirectError(ProcessBuilder.Redirect.INHERIT); 95 | pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); 96 | Process p = pb.start(); 97 | boolean result = p.waitFor(10, TimeUnit.MINUTES); 98 | if (!result) { 99 | p.destroy(); 100 | throw new RuntimeException("Timed out waiting for browsers to install"); 101 | } 102 | if (p.exitValue() != 0) { 103 | throw new RuntimeException("Failed to install browsers, exit code: " + p.exitValue()); 104 | } 105 | } 106 | 107 | private static boolean isExecutable(Path filePath) { 108 | String name = filePath.getFileName().toString(); 109 | return name.endsWith(".sh") || name.endsWith(".exe") || !name.contains("."); 110 | } 111 | 112 | private FileSystem initFileSystem(URI uri) throws IOException { 113 | try { 114 | return FileSystems.newFileSystem(uri, Collections.emptyMap()); 115 | } catch (FileSystemAlreadyExistsException e) { 116 | return null; 117 | } 118 | } 119 | 120 | public static URI getDriverResourceURI() throws URISyntaxException { 121 | // ClassLoader classloader = Thread.currentThread().getContextClassLoader(); 122 | ClassLoader classloader = DriverJar.class.getClassLoader(); 123 | return classloader.getResource("driver/" + platformDir()).toURI(); 124 | } 125 | 126 | void extractDriverToTempDir() throws URISyntaxException, IOException { 127 | URI originalUri = getDriverResourceURI(); 128 | URI uri = maybeExtractNestedJar(originalUri); 129 | 130 | // Create zip filesystem if loading from jar. 131 | try (FileSystem fileSystem = "jar".equals(uri.getScheme()) ? initFileSystem(uri) : null) { 132 | Path srcRoot = Paths.get(uri); 133 | // jar file system's .relativize gives wrong results when used with 134 | // spring-boot-maven-plugin, convert to the default filesystem to 135 | // have predictable results. 136 | // See https://github.com/microsoft/playwright-java/issues/306 137 | Path srcRootDefaultFs = Paths.get(srcRoot.toString()); 138 | Files.walk(srcRoot).forEach(fromPath -> { 139 | if (preinstalledNodePath != null) { 140 | String fileName = fromPath.getFileName().toString(); 141 | if ("node.exe".equals(fileName) || "node".equals(fileName)) { 142 | return; 143 | } 144 | } 145 | Path relative = srcRootDefaultFs.relativize(Paths.get(fromPath.toString())); 146 | Path toPath = driverTempDir.resolve(relative.toString()); 147 | try { 148 | if (Files.isDirectory(fromPath)) { 149 | Files.createDirectories(toPath); 150 | } else { 151 | Files.copy(fromPath, toPath); 152 | if (isExecutable(toPath)) { 153 | toPath.toFile().setExecutable(true, true); 154 | } 155 | } 156 | toPath.toFile().deleteOnExit(); 157 | } catch (IOException e) { 158 | throw new RuntimeException("Failed to extract driver from " + uri + ", full uri: " + originalUri, e); 159 | } 160 | }); 161 | } 162 | } 163 | 164 | private URI maybeExtractNestedJar(final URI uri) throws URISyntaxException { 165 | if (!"jar".equals(uri.getScheme())) { 166 | return uri; 167 | } 168 | final String JAR_URL_SEPARATOR = "!/"; 169 | String[] parts = uri.toString().split("!/"); 170 | if (parts.length != 3) { 171 | return uri; 172 | } 173 | String innerJar = String.join(JAR_URL_SEPARATOR, parts[0], parts[1]); 174 | URI jarUri = new URI(innerJar); 175 | try (FileSystem fs = FileSystems.newFileSystem(jarUri, Collections.emptyMap())) { 176 | Path fromPath = Paths.get(jarUri); 177 | Path toPath = driverTempDir.resolve(fromPath.getFileName().toString()); 178 | Files.copy(fromPath, toPath); 179 | toPath.toFile().deleteOnExit(); 180 | return new URI("jar:" + toPath.toUri() + JAR_URL_SEPARATOR + parts[2]); 181 | } catch (IOException e) { 182 | throw new RuntimeException("Failed to extract driver's nested .jar from " + jarUri + "; full uri: " + uri, e); 183 | } 184 | } 185 | 186 | private static String platformDir() { 187 | String name = System.getProperty("os.name").toLowerCase(); 188 | String arch = System.getProperty("os.arch").toLowerCase(); 189 | 190 | if (name.contains("windows")) { 191 | return "win32_x64"; 192 | } 193 | if (name.contains("linux")) { 194 | if (arch.equals("aarch64")) { 195 | return "linux-arm64"; 196 | } else { 197 | return "linux"; 198 | } 199 | } 200 | if (name.contains("mac os x")) { 201 | if (arch.equals("aarch64")) { 202 | return "mac-arm64"; 203 | } else { 204 | return "mac"; 205 | } 206 | } 207 | throw new RuntimeException("Unexpected os.name value: " + name); 208 | } 209 | 210 | @Override 211 | public Path driverDir() { 212 | return driverTempDir; 213 | } 214 | } --------------------------------------------------------------------------------