├── 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 | [](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: - [[Future]] is already
101 | * completed when [[close]] is called.
- This is a [[CERun]] and the event loop inside the
102 | * VM is empty.
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 | }
--------------------------------------------------------------------------------