├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE.txt ├── README.md ├── build.sbt ├── project ├── VersionHelper.scala ├── Versions.scala ├── build.properties └── plugins.sbt ├── release.sbt └── src ├── main ├── scala-2 │ └── com │ │ └── raquo │ │ └── domtestutils │ │ └── scalatest │ │ ├── AsyncMountSpec.scala │ │ └── MountSpec.scala ├── scala-3 │ └── com │ │ └── raquo │ │ └── domtestutils │ │ └── scalatest │ │ ├── AsyncMountSpec.scala │ │ └── MountSpec.scala └── scala │ └── com │ └── raquo │ └── domtestutils │ ├── EventSimulator.scala │ ├── MountOps.scala │ ├── Utils.scala │ ├── codecs │ ├── Codec.scala │ └── package.scala │ ├── matching │ ├── ExpectedNode.scala │ ├── RuleImplicits.scala │ ├── TestableCompositeKey.scala │ ├── TestableHtmlAttr.scala │ ├── TestableProp.scala │ ├── TestableStyleProp.scala │ ├── TestableSvgAttr.scala │ └── package.scala │ └── scalatest │ ├── DomEnvSpec.scala │ ├── Matchers.scala │ └── ShouldSyntax.scala └── test └── scala └── com └── raquo └── domtestutils ├── TestableCompositeKeySpec.scala ├── TestableHtmlAttrSpec.scala ├── TestablePropSpec.scala ├── TestableStyleSpec.scala ├── TestableSvgAttrSpec.scala ├── UnitSpec.scala └── fixtures ├── Comment.scala ├── SimpleKey.scala └── Tag.scala /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [publish] 6 | tags: ["*"] 7 | 8 | env: 9 | CI: true 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-22.04 14 | strategy: 15 | fail-fast: true 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Setup JVM 20 | uses: actions/setup-java@v3 21 | with: 22 | java-version: '11' 23 | distribution: 'adopt' 24 | - name: Tests 25 | run: sbt +test 26 | - name: Release 27 | run: sbt ci-release 28 | env: 29 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 30 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 31 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 32 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | 7 | env: 8 | CI: true 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18" 20 | - name: Setup JVM 21 | uses: actions/setup-java@v3 22 | with: 23 | java-version: '11' 24 | distribution: 'adopt' 25 | - name: Run tests 26 | run: sbt +test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | .bsp 4 | 5 | .DS_Store 6 | 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | 3 | * @raquo 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2017 Nikita Gazarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala DOM Test Utils 2 | [![Build status](https://github.com/raquo/scala-dom-testutils/actions/workflows/test.yml/badge.svg)](https://github.com/raquo/scala-dom-testutils/actions/workflows/test.yml) 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.raquo/domtestutils_sjs1_3.svg)](https://search.maven.org/artifact/com.raquo/domtestutils_sjs1_3) 4 | 5 | 6 | _Scala DOM Test Utils_ provides a convenient, type-safe way to assert that a real Javascript DOM node matches a certain description using an extensible DSL. 7 | 8 | "com.raquo" %%% "domtestutils" % "" // Scala.js 9 | 10 | You can use _Scala DOM Test Utils_ either directly to make assertions, or you if you're writing a DOM construction / manipulation library, to power its own test utils package. 11 | 12 | 13 | 14 | ## Project Status 15 | 16 | This project exists only to serve the needs of testing [Laminar](https://github.com/raquo/Laminar) and the basic needs of testing Laminar applications. Emphasis on _basic_. This is not going to be a full fledged test kit, nor are there any guarantees of documentation or stability. If you want a something more, you'll need to fork this and/or create your own. It's a very small project anyway. 17 | 18 | **I am very unlikely to accept PRs on this project** – please talk to me before spending your time. 19 | 20 | 21 | 22 | ## Example Test 23 | 24 | ```scala 25 | import com.raquo.laminar.api.L._ 26 | 27 | // Create a JS DOM node that you want to test (example shows optional Laminar syntax) 28 | val jsDomNode: org.scalajs.dom.Node = div( 29 | rel := "yolo" 30 | span("Hello, "), 31 | p("bizzare ", a(href := "http://y2017.com", "2017"), span(" world")), 32 | hr 33 | ).ref 34 | 35 | // Mount the DOM node for testing 36 | mount(jsDomNode, "optional clue to show on failure") 37 | 38 | // Assert that the mounted node matches the provided description (this test will pass given the input above) 39 | expectNode( 40 | div.of( 41 | rel is "yolo", // Ensure that rel attribute is "yolo". Note: assertions for properties and styles work similarly 42 | span.of("Hello, "), // Ensure the this element contains just one text node: "Hello, " 43 | p.of( 44 | "bizzare ", 45 | a.of( 46 | href is "http://y2017.com", 47 | title.isEmpty, // Ensure that title attribute is not set 48 | "2017" 49 | ), 50 | span.of(" world") 51 | ), 52 | hr // Just check existence of element and tag name. Equivalent to `hr like ()` 53 | ) 54 | ) 55 | ``` 56 | 57 | The above example gets `div`, `rel`, `href`, etc. from Laminar, and uses implicit conversions from these Laminar values to DOM TestUtils classes like `ExpectedNode` and `TestableHtmlAttr`.See more usage examples and glue code in [Laminar tests](https://github.com/raquo/Laminar/tree/master/src/test/scala/com/raquo/laminar) 58 | 59 | Laminar is not required to use Scala DOM TestUtils. You can integrate similarly with any other Scala.js UI library. 60 | 61 | 62 | 63 | ## Usage 64 | 65 | Canonical usage is to `mount` one DOM node / tree (e.g. the output of your component) and then test it using the `expectNode` method. 66 | 67 | Alternative is to call `expectNode(actualNode, expectedNode)`, for example if you only want to test a subtree of what you mounted. 68 | 69 | If the mechanics of `MountOps` do not work for you, you can bypass `MountOps` altogether and just call `ExpectedNode.checkNode(actualNode)` directly to get a list of errors. 70 | 71 | **With ScalaTest**: Your test suite should extend the `MountSpec` trait. Use `mount` and `expectNode` methods in your test code. You can call `unmount` and then `mount` again within one test if you want to test multiple unrelated nodes (e.g. different variations in a loop). See Laminar's test suite for an example. 72 | 73 | **Without ScalaTest**: You could write a tiny adapter like `MountSpec` for your test framework, which would: 74 | 75 | - Extend `MountOps` and provide `doAssert` / `doFail` implementations specific to your test framework 76 | - Call `resetDOM` in the beginning of each test, and `clearDOM` at the end of each test. 77 | - However, this project depends on ScalaTest, currently just for the `source.Position` macro, but in the future the integration could be deepened. 78 | - So, long term, you might be better off forking this project if you want to use a different testing library. 79 | - You could probably also forgo `MountOps` and other such files and drop down to calling `ExpectedNode.checkNode(actualNode)` to get a list of errors, and build your own test util around it. 80 | 81 | 82 | 83 | ## Versioning 84 | 85 | There is no promise of any backwards compatibility in this particular project. I _roughly_ align versions with Scala DOM Types for my own convenience. 86 | 87 | 88 | 89 | ## My Related Projects 90 | 91 | - [Laminar](https://github.com/raquo/Laminar) – Reactive UI library based on _Scala DOM Types_ 92 | - [Scala DOM Types](https://github.com/raquo/scala-dom-types) – Type definitions for all the HTML tags, attributes, properties, and styles, used by Laminar and a few other similar libraries 93 | 94 | 95 | 96 | ## Author 97 | 98 | Nikita Gazarov – [@raquo](https://twitter.com/raquo) 99 | 100 | License – MIT 101 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import VersionHelper.{versionFmt, fallbackVersion} 2 | 3 | // Lets me depend on Maven Central artifacts immediately without waiting 4 | resolvers ++= Resolver.sonatypeOssRepos("public") 5 | 6 | // Makes sure to increment the version for local development 7 | ThisBuild / version := dynverGitDescribeOutput.value 8 | .mkVersion(out => versionFmt(out, dynverSonatypeSnapshots.value), fallbackVersion(dynverCurrentDate.value)) 9 | 10 | ThisBuild / dynver := { 11 | val d = new java.util.Date 12 | sbtdynver.DynVer 13 | .getGitDescribeOutput(d) 14 | .mkVersion(out => versionFmt(out, dynverSonatypeSnapshots.value), fallbackVersion(d)) 15 | } 16 | 17 | enablePlugins(ScalaJSBundlerPlugin) 18 | 19 | scalaVersion := Versions.Scala_2_13 20 | 21 | crossScalaVersions := Seq(Versions.Scala_3, Versions.Scala_2_13, Versions.Scala_2_12) 22 | 23 | libraryDependencies ++= Seq( 24 | "org.scala-js" %%% "scalajs-dom" % Versions.ScalaJsDom, 25 | "org.scalatest" %%% "scalatest" % Versions.ScalaTest 26 | ) 27 | 28 | scalacOptions ++= Seq( 29 | "-deprecation", 30 | "-feature", 31 | "-language:higherKinds", 32 | "-language:implicitConversions", 33 | ) 34 | 35 | scalacOptions ++= sys.env.get("CI").map { _ => 36 | val localSourcesPath = (LocalRootProject / baseDirectory).value.toURI 37 | val remoteSourcesPath = s"https://raw.githubusercontent.com/raquo/scala-dom-testutils/${git.gitHeadCommit.value.get}/" 38 | val sourcesOptionName = if (scalaVersion.value.startsWith("2.")) "-P:scalajs:mapSourceURI" else "-scalajs-mapSourceURI" 39 | 40 | s"${sourcesOptionName}:$localSourcesPath->$remoteSourcesPath" 41 | } 42 | 43 | (Test / scalaJSLinkerConfig ~= { 44 | _.withModuleKind(ModuleKind.CommonJSModule) 45 | }) 46 | 47 | (Test / requireJsDomEnv) := true 48 | 49 | (webpack / version) := Versions.Webpack 50 | 51 | (startWebpackDevServer / version) := Versions.WebpackDevServer 52 | 53 | (installJsdom / version) := Versions.JsDom 54 | 55 | useYarn := true 56 | 57 | (Test / parallelExecution) := false 58 | 59 | (Compile / fastOptJS / scalaJSLinkerConfig) ~= { 60 | _.withSourceMap(false) 61 | } 62 | 63 | (Compile / fullOptJS / scalaJSLinkerConfig) ~= { 64 | _.withSourceMap(false) 65 | } 66 | 67 | -------------------------------------------------------------------------------- /project/VersionHelper.scala: -------------------------------------------------------------------------------- 1 | // Helper to increment version when publishing locally 2 | // - Ideally sbt-dynver should take care of this: https://github.com/sbt/sbt-dynver/issues/227 3 | // This entire file was borrowed from Play Framework, licensed under Apache license: 4 | // - https://github.com/playframework/playframework/pull/11168/files 5 | // - https://github.com/playframework/playframework/blob/6e6e94d39566c7ad75f13eba7207b5e93fc0ffa9/project/VersionHelper.scala 6 | 7 | import scala.sys.process.Process 8 | import scala.sys.process.ProcessLogger 9 | 10 | object VersionHelper { 11 | 12 | private val SemVer = """(\d*)\.(\d*)\.(\d*).*""".r 13 | private val SemVerPreVersion = """(\d*)\.(\d*)\.(\d*)-(M|RC)(\d*)""".r 14 | 15 | // For main branch 16 | private def increaseMinorVersion(tag: String): String = { 17 | tag match { 18 | case SemVer(major, minor, patch) => 19 | s"$major.${minor.toInt + 1}.0" 20 | case _ => 21 | tag 22 | } 23 | } 24 | 25 | // For version branches 26 | private def increasePatchVersion(tag: String): String = { 27 | tag match { 28 | case SemVer(major, minor, patch) => 29 | s"$major.$minor.${patch.toInt + 1}" 30 | case _ => 31 | tag 32 | } 33 | } 34 | 35 | // For release candidates (-RC) and milestones (-M), no matter which branch 36 | private def increasePreVersion(tag: String): String = { 37 | tag match { 38 | case SemVerPreVersion(major, minor, patch, preVersionType, preVersion) => 39 | s"$major.$minor.$patch-$preVersionType${preVersion.toInt + 1}" 40 | case _ => 41 | tag 42 | } 43 | } 44 | 45 | def versionFmt(out: sbtdynver.GitDescribeOutput, dynverSonatypeSnapshots: Boolean): String = { 46 | if (out.isCleanAfterTag) { 47 | out.ref.dropPrefix 48 | } else { 49 | val dirtyPart = if (out.isDirty()) out.dirtySuffix.value else "" 50 | val snapshotPart = if (dynverSonatypeSnapshots && out.isSnapshot()) "-SNAPSHOT" else "" 51 | val isCI = sys.env.get("CI").exists(_.toBoolean) 52 | (if (out.ref.dropPrefix.matches(""".*-(M|RC)\d+$""")) { 53 | // tag is a milestone or release candidate, therefore we increase the version after the -RC or -M (e.g. -RC1 becomes -RC2) 54 | // it does not matter on which branch we are on 55 | VersionHelper.increasePreVersion(out.ref.dropPrefix) 56 | } else { 57 | val mainBranchIsAncestor = 58 | Process("git merge-base --is-ancestor main HEAD").run(ProcessLogger(_ => ())).exitValue() == 0 59 | lazy val masterBranchIsAncestor = 60 | Process("git merge-base --is-ancestor master HEAD").run(ProcessLogger(_ => ())).exitValue() == 0 61 | if (mainBranchIsAncestor || masterBranchIsAncestor) { 62 | // We are on the main (or master) branch, or a branch that is forked off from the main branch 63 | VersionHelper.increaseMinorVersion(out.ref.dropPrefix) 64 | } else { 65 | // We are not on the main (or master) branch or one off its children. 66 | // Therefore we are e.g. on 2.8.x or a branch that is forked off from 2.8.x or 2.9.x or ... you get it ;) 67 | VersionHelper.increasePatchVersion(out.ref.dropPrefix) 68 | } 69 | }) + (if (isCI) Option(out.commitSuffix.sha).filter(_.nonEmpty).map("-" + _).getOrElse("") + dirtyPart 70 | else "") + snapshotPart 71 | } 72 | } 73 | 74 | def fallbackVersion(d: java.util.Date): String = s"HEAD-${sbtdynver.DynVer.timestamp(d)}" 75 | 76 | } 77 | -------------------------------------------------------------------------------- /project/Versions.scala: -------------------------------------------------------------------------------- 1 | object Versions { 2 | 3 | val Scala_2_12 = "2.12.18" 4 | 5 | val Scala_2_13 = "2.13.12" 6 | 7 | val Scala_3 = "3.3.1" 8 | 9 | // -- Dependencies -- 10 | 11 | val ScalaJsDom = "2.3.0" 12 | 13 | // -- Test -- 14 | 15 | val ScalaTest = "3.2.10" 16 | 17 | val JsDom = "20.0.3" 18 | 19 | val Webpack = "5.75.0" 20 | 21 | val WebpackDevServer = "4.11.1" 22 | } 23 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.9.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn 2 | 3 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.14.0") 4 | 5 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") 6 | 7 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") 8 | 9 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.11") 10 | 11 | addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") 12 | -------------------------------------------------------------------------------- /release.sbt: -------------------------------------------------------------------------------- 1 | name := "Scala DOM Test Utils" 2 | 3 | normalizedName := "domtestutils" 4 | 5 | organization := "com.raquo" 6 | 7 | homepage := Some(url("https://github.com/raquo/scala-dom-testutils")) 8 | 9 | licenses += ("MIT", url("https://github.com/raquo/scala-dom-testutils/blob/master/LICENSE.md")) 10 | 11 | scmInfo := Some( 12 | ScmInfo( 13 | url("https://github.com/raquo/scala-dom-testutils"), 14 | "scm:git@github.com/raquo/scala-dom-testutils.git" 15 | ) 16 | ) 17 | 18 | developers := List( 19 | Developer( 20 | id = "raquo", 21 | name = "Nikita Gazarov", 22 | email = "nikita@raquo.com", 23 | url = url("https://github.com/raquo") 24 | ) 25 | ) 26 | 27 | (Test / publishArtifact) := false 28 | 29 | pomIncludeRepository := { _ => false } 30 | 31 | sonatypeCredentialHost := "s01.oss.sonatype.org" 32 | 33 | sonatypeRepository := "https://s01.oss.sonatype.org/service/local" 34 | -------------------------------------------------------------------------------- /src/main/scala-2/com/raquo/domtestutils/scalatest/AsyncMountSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import com.raquo.domtestutils.MountOps 4 | import org.scalactic 5 | import org.scalatest.{AsyncTestSuite, FutureOutcome} 6 | 7 | import scala.concurrent.ExecutionContext 8 | import scala.scalajs.concurrent.JSExecutionContext 9 | 10 | trait AsyncMountSpec 11 | extends AsyncTestSuite 12 | with MountOps { 13 | 14 | implicit override def executionContext: ExecutionContext = JSExecutionContext.queue 15 | 16 | override def doAssert(condition: Boolean, message: String)(implicit prettifier: scalactic.Prettifier, pos: scalactic.source.Position): Unit = { 17 | assert(condition, message)(prettifier, pos) 18 | } 19 | 20 | override def doFail(message: String)(implicit pos: scalactic.source.Position): Nothing = { 21 | fail(message) //(pos) 22 | } 23 | 24 | /** Note: we use withFixture instead of beforeEach/afterEach because 25 | * ScalaTest obscures error messages reported from the latter. 26 | */ 27 | override def withFixture(test: NoArgAsyncTest): FutureOutcome = { 28 | resetDOM("async-withFixture-begin") // Runs in the beginning of each test 29 | super.withFixture(test).onCompletedThen(_ => { 30 | clearDOM("async-withFixture-end") // Runs at the end of each test, regardless of the result 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala-2/com/raquo/domtestutils/scalatest/MountSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import com.raquo.domtestutils.MountOps 4 | import org.scalactic 5 | import org.scalatest.{Outcome, TestSuite} 6 | 7 | trait MountSpec 8 | extends TestSuite 9 | with MountOps { 10 | 11 | override def doAssert(condition: Boolean, message: String)( 12 | implicit prettifier: scalactic.Prettifier, 13 | pos: scalactic.source.Position 14 | ): Unit = { 15 | assert(condition, message)(prettifier, pos) 16 | } 17 | 18 | override def doFail(message: String)(implicit pos: scalactic.source.Position): Nothing = { 19 | fail(message)(pos) 20 | } 21 | 22 | /** Note: we use withFixture instead of beforeEach/afterEach because 23 | * ScalaTest obscures error messages reported from the latter. 24 | */ 25 | override def withFixture(test: NoArgTest): Outcome = { 26 | resetDOM("withFixture-begin") // Runs in the beginning of each test 27 | try { 28 | super.withFixture(test) 29 | } finally { 30 | clearDOM("withFixture-end") // Runs at the end of each test, regardless of the result 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/domtestutils/scalatest/AsyncMountSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import com.raquo.domtestutils.MountOps 4 | import org.scalactic 5 | import org.scalatest.{AsyncTestSuite, FutureOutcome} 6 | import org.scalatest.exceptions.StackDepthException 7 | import org.scalatest.exceptions.TestFailedException 8 | 9 | import scala.concurrent.ExecutionContext 10 | import scala.scalajs.concurrent.JSExecutionContext 11 | 12 | trait AsyncMountSpec 13 | extends AsyncTestSuite 14 | with MountOps { 15 | 16 | implicit override def executionContext: ExecutionContext = JSExecutionContext.queue 17 | 18 | override def doAssert(condition: Boolean, message: String)(implicit prettifier: scalactic.Prettifier, pos: scalactic.source.Position): Unit = { 19 | assert(condition, message)(prettifier, pos, UseDefaultAssertions) 20 | } 21 | 22 | override def doFail(message: String)(implicit pos: scalactic.source.Position): Nothing = { 23 | throw new TestFailedException(toExceptionFunction(Some(message)), None, Left(pos), None, Vector.empty) 24 | } 25 | 26 | /** Note: we use withFixture instead of beforeEach/afterEach because 27 | * ScalaTest obscures error messages reported from the latter. 28 | */ 29 | override def withFixture(test: NoArgAsyncTest): FutureOutcome = { 30 | resetDOM("async-withFixture-begin") // Runs in the beginning of each test 31 | super.withFixture(test).onCompletedThen(_ => { 32 | clearDOM("async-withFixture-end") // Runs at the end of each test, regardless of the result 33 | }) 34 | } 35 | 36 | // Copy of ScalaTest's function of the same name, because that one is scalatest-private. 37 | private def toExceptionFunction(message: Option[String]): StackDepthException => Option[String] = { 38 | message match { 39 | case null => throw new org.scalactic.exceptions.NullArgumentException("message was null") 40 | case Some(null) => throw new org.scalactic.exceptions.NullArgumentException("message was Some(null)") 41 | case _ => { e => message } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/domtestutils/scalatest/MountSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import com.raquo.domtestutils.MountOps 4 | import org.scalactic 5 | import org.scalatest.{Outcome, TestSuite} 6 | import org.scalatest.exceptions.StackDepthException 7 | import org.scalatest.exceptions.TestFailedException 8 | 9 | trait MountSpec 10 | extends TestSuite 11 | with MountOps { 12 | 13 | override def doAssert( 14 | condition: Boolean, 15 | message: String 16 | )( 17 | implicit prettifier: scalactic.Prettifier, 18 | pos: scalactic.source.Position 19 | ): Unit = { 20 | assert(condition, message)(prettifier, pos, UseDefaultAssertions) 21 | } 22 | 23 | override def doFail(message: String)(implicit pos: scalactic.source.Position): Nothing = { 24 | throw new TestFailedException(toExceptionFunction(Some(message)), None, Left(pos), None, Vector.empty) 25 | } 26 | 27 | /** Note: we use withFixture instead of beforeEach/afterEach because 28 | * ScalaTest obscures error messages reported from the latter. 29 | */ 30 | override def withFixture(test: NoArgTest): Outcome = { 31 | resetDOM("withFixture-begin") // Runs in the beginning of each test 32 | try { 33 | super.withFixture(test) 34 | } finally { 35 | clearDOM("withFixture-end") // Runs at the end of each test, regardless of the result 36 | } 37 | } 38 | 39 | // Copy of ScalaTest's function of the same name, because that one is scalatest-private. 40 | private def toExceptionFunction(message: Option[String]): StackDepthException => Option[String] = { 41 | message match { 42 | case null => throw new org.scalactic.exceptions.NullArgumentException("message was null") 43 | case Some(null) => throw new org.scalactic.exceptions.NullArgumentException("message was Some(null)") 44 | case _ => { e => message } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/EventSimulator.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import org.scalajs.dom 4 | import org.scalajs.dom.WheelEventInit 5 | 6 | import scala.scalajs.js 7 | 8 | trait EventSimulator { 9 | 10 | // @TODO[API] Make a simple simulator package 11 | // @TODO This could be more compatible and more configurable 12 | // @TODO see if we can use https://github.com/Rich-Harris/simulant 13 | 14 | @deprecated("Not needed in jsdom anymore", "0.10") 15 | def simulateClick(target: dom.html.Element): Unit = { 16 | // val evt = dom.document.createEvent("HTMLEvents") 17 | // evt.initEvent("click", canBubbleArg = true, cancelableArg = true) 18 | // target.dispatchEvent(evt) 19 | 20 | target.click() 21 | } 22 | 23 | // SVG elements don't have a click() method, so... 24 | def simulateClick(target: dom.svg.Element): Unit = { 25 | simulatePointerEvent("click", target) 26 | } 27 | 28 | // Text nodes don't have a click() method, so... 29 | def simulateClick(target: dom.Text): Unit = { 30 | simulatePointerEvent("click", target) 31 | } 32 | 33 | def simulateScroll(target: dom.Node): Unit = { 34 | val scrollOpts = new WheelEventInit { 35 | // override val view: js.UndefOr[dom.Window] = dom.window // #TODO scalajs-dom v2.1.0 made this impossible, what now? 36 | } 37 | scrollOpts.bubbles = true 38 | scrollOpts.cancelable = true 39 | scrollOpts.composed = false 40 | val evt = new dom.Event("scroll", scrollOpts) 41 | target.dispatchEvent(evt) 42 | } 43 | 44 | /** @param eventType e.g. "click" */ 45 | private def simulatePointerEvent(eventType: String, target: dom.Node): Unit = { 46 | val pointerOpts = new dom.PointerEventInit { 47 | // override val view: js.UndefOr[dom.Window] = dom.window // #TODO scalajs-dom v2.1.0 made this impossible, what now? 48 | } 49 | pointerOpts.bubbles = true 50 | pointerOpts.cancelable = true 51 | pointerOpts.composed = false 52 | val evt = new dom.Event(eventType, pointerOpts) 53 | target.dispatchEvent(evt) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/MountOps.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.matching.ExpectedNode 4 | import org.scalactic 5 | import org.scalajs.dom 6 | 7 | /** Utilities for mounting / unmounting a single DOM node and running assertions on it. 8 | * This functionality can be used with DOM nodes created by any means, you don't need to use Scala DOM Builder. 9 | * This trait is (almost) agnostic of the testing frameworks. We have an adapter for ScalaTest, see MountSpec. 10 | */ 11 | trait MountOps { 12 | 13 | // === On nullable variables === 14 | // `container` and `rootNode` are nullable because if they were an Option it would be too easy to 15 | // forget to handle the `None` case when mapping or foreach-ing over them. 16 | // In test code, We'd rather have a null pointer exception than an assertion that you don't 17 | // realize isn't running because it's inside a None.foreach. 18 | 19 | /** Container element that will hold the root node as a child. Container is mounted as a child of element */ 20 | var containerNode: dom.Element = null // see "On nullable variables" comment above ^^^ 21 | 22 | /** Root node is the node that we test in `expectNode`. It is the only child of the `containerNode` element */ 23 | def rootNode: dom.Node = Option(containerNode).map(_.firstChild).orNull // see "On nullable variables" comment above ^^^ 24 | 25 | val defaultMountedElementClue = "root" 26 | 27 | /** Prefix to add to error messages – useful to differentiate between different mount() calls within one test */ 28 | var mountedElementClue: String = defaultMountedElementClue 29 | 30 | // @TODO[API] the errors printed out by these functions don't show the original line numbers. How can we fix that? 31 | 32 | /** If condition is false, fail the test with a given message 33 | * This method exists for compatibility with different test frameworks. 34 | */ 35 | def doAssert( 36 | condition: Boolean, 37 | message: String 38 | )( 39 | implicit prettifier: scalactic.Prettifier, 40 | pos: scalactic.source.Position, 41 | ): Unit 42 | 43 | /** Fail the test with a given message 44 | * This method exists for compatibility with different test frameworks. 45 | */ 46 | def doFail(message: String)(implicit pos: scalactic.source.Position): Nothing 47 | 48 | /** Check that the root node matches the provided description. Call doFail with an error message if the test fails. */ 49 | def expectNode(expectedNode: ExpectedNode)(implicit pos: scalactic.source.Position): Unit = { 50 | rootNode match { 51 | case null => 52 | doFail(s"ASSERT FAIL [expectNode]: Root node not found. Did you forget to mount()?")(pos) 53 | case _ => 54 | val errors = expectedNode.checkNode(rootNode, clue = mountedElementClue) 55 | if (errors.nonEmpty) { 56 | doFail(s"Rendered element does not match expectations:\n${errors.mkString("\n")}")(pos) 57 | } 58 | } 59 | } 60 | 61 | /** Check that a given node matches the provided description. Call doFail with an error message if the test fails. */ 62 | def expectNode( 63 | actualNode: dom.Node, 64 | expectedNode: ExpectedNode, 65 | clue: String = mountedElementClue 66 | )( 67 | implicit pos: scalactic.source.Position 68 | ): Unit = { 69 | val errors = expectedNode.checkNode(actualNode, clue) 70 | if (errors.nonEmpty) { 71 | doFail(s"Given node does not match expectations:\n${errors.mkString("\n")}")(pos) 72 | } 73 | } 74 | 75 | /** Inject the root node into the DOM – with default clue 76 | * Note: [[defaultMountedElementClue]] should not be made a default value on the above `mount` method 77 | * because that prevents users from defining their own `mount` methods that accept default arguments 78 | * ("multiple overloaded alternatives of method mount define default arguments") error 79 | */ 80 | def mount( 81 | node: dom.Node 82 | )( 83 | implicit prettifier: scalactic.Prettifier, 84 | pos: scalactic.source.Position 85 | ): Unit = { 86 | mount(node, defaultMountedElementClue)(prettifier, pos) 87 | } 88 | 89 | /** Inject the root node into the DOM */ 90 | def mount( 91 | node: dom.Node, 92 | clue: String 93 | )( 94 | implicit prettifier: scalactic.Prettifier, 95 | pos: scalactic.source.Position 96 | ): Unit = { 97 | assertEmptyContainer("mount")(prettifier, pos) 98 | mountedElementClue = clue 99 | containerNode.appendChild(node) 100 | } 101 | 102 | /** Inject the root node into the DOM – alternative argument order for convenience */ 103 | def mount( 104 | clue: String, 105 | node: dom.Node 106 | )( 107 | implicit prettifier: scalactic.Prettifier, 108 | pos: scalactic.source.Position 109 | ): Unit = { 110 | mount(node, clue)(prettifier, pos) 111 | } 112 | 113 | /** Remove root node from the DOM */ 114 | def unmount( 115 | clue: String = "unmount" 116 | )( 117 | implicit prettifier: scalactic.Prettifier, 118 | pos: scalactic.source.Position 119 | ): Unit = { 120 | assertRootNodeMounted("unmount:" + clue)(prettifier, pos) 121 | rootNode.parentNode.removeChild(rootNode) 122 | mountedElementClue = defaultMountedElementClue 123 | } 124 | 125 | /** Remove all traces of previous tests from the DOM: Unmount the root node and remove the container from the DOM */ 126 | def clearDOM( 127 | clue: String = "clearDOM" 128 | )( 129 | implicit prettifier: scalactic.Prettifier, 130 | pos: scalactic.source.Position 131 | ): Unit = { 132 | if (rootNode != null) { 133 | unmount("clearDOM:" + clue)(prettifier, pos) 134 | } 135 | containerNode = null 136 | dom.document.body.textContent = "" // remove the container 137 | } 138 | 139 | /** Clear the DOM and create a new container. This should be called before each test. */ 140 | def resetDOM( 141 | clue: String = "resetDOM" 142 | )( 143 | implicit prettifier: scalactic.Prettifier, 144 | pos: scalactic.source.Position 145 | ): Unit = { 146 | clearDOM("resetDOM:" + clue)(prettifier, pos) 147 | val newContainer = createContainer() 148 | dom.document.body.appendChild(newContainer) 149 | containerNode = newContainer 150 | } 151 | 152 | def createContainer(): dom.Element = { 153 | val container = dom.document.createElement("div") 154 | container.setAttribute("id", "app-container") 155 | container 156 | } 157 | 158 | def assertEmptyContainer(clue: String)(implicit prettifier: scalactic.Prettifier, pos: scalactic.source.Position): Unit = { 159 | containerNode match { 160 | case null => 161 | doFail(s"ASSERT FAIL [$clue]: Container not found. Usually, resetDOM() creates the container in withFixture().")(pos) 162 | case _ => 163 | doAssert( 164 | containerNode.parentNode == dom.document.body, 165 | s"ASSERT FAIL [$clue]: Container is not mounted to (what did you do!?)." 166 | )(prettifier, pos) 167 | doAssert( 168 | containerNode.firstChild == null, 169 | s"ASSERT FAIL [$clue]: Unexpected children in container. Did you forget to unmount() the previous node?" 170 | )(prettifier, pos) 171 | doAssert( 172 | rootNode == null, 173 | s"ASSERT FAIL [$clue]: Can not override non-null rootNode variable in mount(). Did you override unmount() method and forget to set rootNode = null?" 174 | )(prettifier, pos) 175 | } 176 | } 177 | 178 | def assertRootNodeMounted(clue: String)(implicit prettifier: scalactic.Prettifier, pos: scalactic.source.Position): Unit = { 179 | doAssert( 180 | rootNode != null, 181 | s"ASSERT FAIL [$clue]: There is no root node to unmount. Did you forget to mount()?" 182 | )(prettifier, pos) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/Utils.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import scala.util.Random 4 | 5 | trait Utils { 6 | 7 | // @TODO[API] this doesn't really belong here 8 | def randomString(prefix: String = "", length: Int = 5): String = { 9 | prefix + Random.nextString(length) 10 | } 11 | 12 | def repr(value: Any): String = { 13 | value match { 14 | case null => "null" 15 | case str: String => "\"" + str + "\"" 16 | case _ => value.toString 17 | } 18 | } 19 | } 20 | 21 | object Utils extends Utils 22 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/codecs/Codec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.codecs 2 | 3 | /** This trait represents a way to encode and decode HTML attribute or DOM property values. 4 | * 5 | * It is needed because attributes encode all values as strings regardless of their type, 6 | * and then there are also multiple ways to encode e.g. boolean values. Some attributes 7 | * encode those as "true" / "false" strings, others as presence or absence of the element, 8 | * and yet others use "yes" / "no" or "on" / "off" strings, and properties encode booleans 9 | * as actual booleans. 10 | * 11 | * Scala DOM Types hides all this mess from you using codecs. All those pseudo-boolean 12 | * attributes would be simply `Attr[Boolean](name, codec)` in your code. 13 | * */ 14 | trait Codec[ScalaType, DomType] { 15 | 16 | /** Convert the result of a `dom.Node.getAttribute` call to appropriate Scala type. 17 | * 18 | * Note: HTML Attributes are generally optional, and `dom.Node.getAttribute` will return 19 | * `null` if an attribute is not defined on a given DOM node. However, this decoder is 20 | * only intended for cases when the attribute is defined. 21 | */ 22 | def decode(domValue: DomType): ScalaType 23 | 24 | /** Convert desired attribute value to appropriate DOM type. The resulting value should 25 | * be passed to `dom.Node.setAttribute` call, EXCEPT when resulting value is a `null`. 26 | * In that case you should call `dom.Node.removeAttribute` instead. 27 | * 28 | * We use `null` instead of [[Option]] here to reduce overhead in JS land. This method 29 | * should not be called by end users anyway, it's the consuming library's job to 30 | * call this method under the hood. 31 | */ 32 | def encode(scalaValue: ScalaType): DomType 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/codecs/package.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | package object codecs { 4 | 5 | def AsIsCodec[V](): Codec[V, V] = new Codec[V, V] { 6 | override def encode(scalaValue: V): V = scalaValue 7 | 8 | override def decode(domValue: V): V = domValue 9 | } 10 | 11 | // String Codecs 12 | 13 | val StringAsIsCodec: Codec[String, String] = AsIsCodec() 14 | 15 | // Int Codecs 16 | 17 | val IntAsIsCodec: Codec[Int, Int] = AsIsCodec() 18 | 19 | lazy val IntAsStringCodec: Codec[Int, String] = new Codec[Int, String] { 20 | 21 | override def decode(domValue: String): Int = domValue.toInt // @TODO this can throw exception. How do we handle this? 22 | 23 | override def encode(scalaValue: Int): String = scalaValue.toString 24 | } 25 | 26 | // Double Codecs 27 | 28 | lazy val DoubleAsIsCodec: Codec[Double, Double] = AsIsCodec() 29 | 30 | lazy val DoubleAsStringCodec: Codec[Double, String] = new Codec[Double, String] { 31 | 32 | override def decode(domValue: String): Double = domValue.toDouble // @TODO this can throw exception. How do we handle this? 33 | 34 | override def encode(scalaValue: Double): String = scalaValue.toString 35 | } 36 | 37 | // Boolean Codecs 38 | 39 | val BooleanAsIsCodec: Codec[Boolean, Boolean] = AsIsCodec() 40 | 41 | val BooleanAsAttrPresenceCodec: Codec[Boolean, String] = new Codec[Boolean, String] { 42 | 43 | override def decode(domValue: String): Boolean = domValue != null 44 | 45 | override def encode(scalaValue: Boolean): String = if (scalaValue) "" else null 46 | } 47 | 48 | lazy val BooleanAsTrueFalseStringCodec: Codec[Boolean, String] = new Codec[Boolean, String] { 49 | 50 | override def decode(domValue: String): Boolean = domValue == "true" 51 | 52 | override def encode(scalaValue: Boolean): String = if (scalaValue) "true" else "false" 53 | } 54 | 55 | lazy val BooleanAsYesNoStringCodec: Codec[Boolean, String] = new Codec[Boolean, String] { 56 | 57 | override def decode(domValue: String): Boolean = domValue == "yes" 58 | 59 | override def encode(scalaValue: Boolean): String = if (scalaValue) "yes" else "no" 60 | } 61 | 62 | lazy val BooleanAsOnOffStringCodec: Codec[Boolean, String] = new Codec[Boolean, String] { 63 | 64 | override def decode(domValue: String): Boolean = domValue == "on" 65 | 66 | override def encode(scalaValue: Boolean): String = if (scalaValue) "on" else "off" 67 | } 68 | 69 | // Iterable Codecs 70 | 71 | lazy val IterableAsSpaceSeparatedStringCodec: Codec[Iterable[String], String] = new Codec[Iterable[String], String] { // could use for e.g. className 72 | 73 | override def decode(domValue: String): Iterable[String] = if (domValue == "") Nil else domValue.split(' ') 74 | 75 | override def encode(scalaValue: Iterable[String]): String = scalaValue.mkString(" ") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/ExpectedNode.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | 6 | import scala.collection.mutable 7 | 8 | class ExpectedNode protected ( 9 | val maybeTagName: Option[String] = None, 10 | val isTextNode: Boolean = false, 11 | val isComment: Boolean = false 12 | ) { 13 | 14 | import ExpectedNode._ 15 | 16 | // @TODO[Integrity] Write tests for this test util; it's quite complicated. 17 | 18 | val nodeType: String = (maybeTagName, isTextNode, isComment) match { 19 | case (Some(tagName), false, false) => s"Element[$tagName]" 20 | case (None, true, false) => "Text" 21 | case (None, false, true) => "Comment" 22 | case _ => "[ExpectedNode.nodeType: InvalidNode]" 23 | } 24 | 25 | private val expectedChildrenBuffer: mutable.Buffer[ExpectedNode] = mutable.Buffer() 26 | 27 | private val checksBuffer: mutable.Buffer[Check] = mutable.Buffer( 28 | ExpectedNode.checkNodeType(nodeType) 29 | ) 30 | 31 | def checks: List[Check] = checksBuffer.toList 32 | 33 | def expectedChildren: List[ExpectedNode] = expectedChildrenBuffer.toList 34 | 35 | def addCheck(check: Check): Unit = { 36 | checksBuffer.append(check) 37 | } 38 | 39 | def addExpectedChild(child: ExpectedNode): Unit = { 40 | expectedChildrenBuffer.append(child) 41 | } 42 | 43 | def of(rules: Rule*): ExpectedNode = { 44 | rules.foreach(rule => rule.applyTo(this)) 45 | this 46 | } 47 | 48 | def like(rules: Rule*): ExpectedNode = { 49 | of(rules: _*) 50 | } 51 | 52 | def checkNode(node: dom.Node, clue: String): ErrorList = { 53 | val errorsFromThisNode: ErrorList = checks 54 | .flatMap(check => check(node)) 55 | .map(error => withClue(clue, error)) 56 | 57 | val actualNumChildren = node.childNodes.length 58 | val expectedNumChildren = expectedChildren.length 59 | 60 | val childErrors = if (actualNumChildren != expectedNumChildren) { 61 | List( 62 | withClue(clue = clue, s"Child nodes length mismatch: actual ${repr(actualNumChildren)}, expected ${repr(expectedNumChildren)}"), 63 | withClue(clue = clue, s"- Detailed comparison:\n actual - ${repr(nodeListToList(node.childNodes))},\n expected - ${repr(expectedChildren)}") 64 | ) 65 | } else { 66 | expectedChildren.zipWithIndex.flatMap { 67 | case (expectedChildElement, index) => 68 | expectedChildElement.checkNode( 69 | node = node.childNodes(index), 70 | clue = s"$clue --- @$index" 71 | ) 72 | } 73 | } 74 | 75 | errorsFromThisNode.distinct ++ childErrors 76 | } 77 | 78 | // @TODO[Convenience] The checks that we apply could also report what they're doing here 79 | override def toString: String = { 80 | (maybeTagName, isTextNode, isComment) match { 81 | case (Some(tagName), false, false) => 82 | s"ExpectedNode[Element,tag=${repr(tagName)}]" 83 | case (None, true, false) => 84 | s"ExpectedNode[Text]" 85 | case (None, false, true) => 86 | s"ExpectedNode[Comment]" 87 | case _ => 88 | throw new Exception("ExpectedNode.toString: inconsistent state") 89 | } 90 | } 91 | } 92 | 93 | object ExpectedNode { 94 | 95 | def element(tagName: String): ExpectedNode = new ExpectedNode(maybeTagName = Some(tagName)) 96 | 97 | def comment: ExpectedNode = new ExpectedNode(isComment = true) 98 | 99 | def textNode: ExpectedNode = new ExpectedNode(isTextNode = true) 100 | 101 | def withClue(clue: String, message: String): String = { 102 | s"[$clue]: $message" 103 | } 104 | 105 | def nodeType(node: dom.Node): String = { 106 | node match { 107 | case el: dom.Element => s"Element[${el.tagName.toLowerCase}]" 108 | case t: dom.Text => "Text" 109 | case c: dom.Comment => "Comment" 110 | } 111 | } 112 | 113 | def checkNodeType(expectedNodeType: String)(node: dom.Node): MaybeError = { 114 | val actualNodeType = nodeType(node) 115 | if (actualNodeType == expectedNodeType) { 116 | None 117 | } else { 118 | Some(s"Node type mismatch: actual node is a ${repr(actualNodeType)}, expected a ${repr(expectedNodeType)}") 119 | } 120 | } 121 | 122 | def checkText(expectedText: String)(node: dom.Node): MaybeError = { 123 | checkNodeType("Text")(node).orElse { 124 | if (node.textContent != expectedText) { 125 | Some(s"Text node textContent mismatch: actual ${repr(node.textContent)}, expected ${repr(expectedText)}") 126 | } else { 127 | None 128 | } 129 | } 130 | } 131 | 132 | def checkCommentText(expectedText: String)(node: dom.Node): MaybeError = { 133 | checkNodeType("Comment")(node).orElse { 134 | if (node.textContent != expectedText) { 135 | Some(s"Comment node text mismatch: actual ${repr(node.textContent)}, expected ${repr(expectedText)}") 136 | } else { 137 | None 138 | } 139 | } 140 | } 141 | 142 | def nodeListToList(nodeList: dom.NodeList[dom.Node]): List[dom.Node] = { 143 | // @TODO[Polish] Move into JSUtils 144 | var result: List[dom.Node] = Nil 145 | var i = 0 146 | while (i < nodeList.length) { 147 | result = result :+ nodeList(i) 148 | i += 1 149 | } 150 | result 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/RuleImplicits.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | trait RuleImplicits[Tag, Comment, Prop[_, _], HtmlAttr[_], SvgAttr[_], Style[_], CompositeHtmlKey, CompositeSvgKey] { 4 | 5 | implicit def makeTagTestable(tag: Tag): ExpectedNode 6 | 7 | implicit def makeCommentBuilderTestable(commentBuilder: () => Comment): ExpectedNode 8 | 9 | implicit def makeAttrTestable[V](attr: HtmlAttr[V]): TestableHtmlAttr[V] 10 | 11 | implicit def makePropTestable[V, DomV](prop: Prop[V, DomV]): TestableProp[V, DomV] 12 | 13 | implicit def makeStyleTestable[V](style: Style[V]): TestableStyleProp[V] 14 | 15 | implicit def makeSvgAttrTestable[V](svgAttr: SvgAttr[V]): TestableSvgAttr[V] 16 | 17 | implicit def makeCompositeHtmlKeyTestable(key: CompositeHtmlKey): TestableCompositeKey 18 | 19 | implicit def makeCompositeSvgKeyTestable(key: CompositeSvgKey): TestableCompositeKey 20 | 21 | // Converters 22 | 23 | implicit def expectedNodeAsExpectedChildRule(expectedChild: ExpectedNode): Rule = (expectedParent: ExpectedNode) => { 24 | expectedParent.addExpectedChild(expectedChild) 25 | } 26 | 27 | implicit def tagAsExpectedChildRule(tag: Tag): Rule = (expectedParent: ExpectedNode) => { 28 | val expectedChild: ExpectedNode = makeTagTestable(tag) 29 | expectedParent.addExpectedChild(expectedChild) 30 | } 31 | 32 | implicit def commentBuilderAsExpectedChildRule(commentBuilder: () => Comment): Rule = (expectedParent: ExpectedNode) => { 33 | val expectedChild: ExpectedNode = makeCommentBuilderTestable(commentBuilder) 34 | expectedParent.addExpectedChild(expectedChild) 35 | } 36 | 37 | implicit def stringAsExpectedTextRule(childText: String): Rule = (expectedParent: ExpectedNode) => { 38 | if (expectedParent.isComment) { 39 | expectedParent.addCheck(ExpectedNode.checkCommentText(childText)) 40 | } else { 41 | val expectedTextChild = ExpectedNode.textNode 42 | expectedTextChild.addCheck(ExpectedNode.checkText(childText)) 43 | expectedParent.addExpectedChild(expectedTextChild) 44 | } 45 | } 46 | 47 | // Option-based converters 48 | 49 | implicit def maybeExpectedNodeAsExpectedChildRule(maybeExpectedChild: Option[ExpectedNode]): Rule = (expectedParent: ExpectedNode) => { 50 | maybeExpectedChild.foreach(expectedParent.addExpectedChild) 51 | } 52 | 53 | implicit def maybeTagAsExpectedChildRule(maybeTag: Option[Tag]): Rule = (expectedParent: ExpectedNode) => { 54 | maybeTag.foreach(tagAsExpectedChildRule(_).applyTo(expectedParent)) 55 | } 56 | 57 | implicit def maybeCommentBuilderAsExpectedChildRule(maybeCommentBuilder: Option[() => Comment]): Rule = (expectedParent: ExpectedNode) => { 58 | maybeCommentBuilder.foreach(commentBuilderAsExpectedChildRule(_).applyTo(expectedParent)) 59 | } 60 | 61 | implicit def maybeStringAsExpectedTextRule(maybeChildText: Option[String]): Rule = (expectedParent: ExpectedNode) => { 62 | maybeChildText.foreach(stringAsExpectedTextRule(_).applyTo(expectedParent)) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/TestableCompositeKey.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | import org.scalajs.dom.Element 6 | 7 | class TestableCompositeKey( 8 | val name: String, 9 | val separator: String, 10 | getRawDomValue: PartialFunction[dom.Element, String] 11 | ) { 12 | 13 | def is(expectedValue: String): Rule = (testNode: ExpectedNode) => { 14 | testNode.addCheck(nodeKeyIs(maybeExpectedValue = Some(expectedValue))) 15 | } 16 | 17 | def isEmpty: Rule = (testNode: ExpectedNode) => { 18 | testNode.addCheck(nodeKeyIs(maybeExpectedValue = None)) 19 | } 20 | 21 | private[domtestutils] def nodeKeyIs(maybeExpectedValue: Option[String])(node: dom.Node): MaybeError = { 22 | val maybeActualValue = getDomValue(node) 23 | node match { 24 | case element: Element => 25 | (maybeActualValue, maybeExpectedValue) match { 26 | 27 | case (Some(actualValue), Some(expectedValue)) => 28 | if (actualValue == expectedValue) { 29 | None 30 | } else { 31 | Some( 32 | s"""|CompositeKey `${name}` value is incorrect: 33 | |- Actual: ${repr(actualValue)} 34 | |- Expected: ${repr(expectedValue)} 35 | |""".stripMargin) 36 | } 37 | 38 | case (None, Some(expectedValue)) => 39 | val rawActualValue = getRawDomValue.applyOrElse(element, default = null) 40 | Some( 41 | s"""|CompositeKey `${name}` is empty or missing: 42 | |- Actual (raw): ${repr(rawActualValue)} 43 | |- Expected: ${repr(expectedValue)} 44 | |""".stripMargin) 45 | 46 | case (Some(actualValue), None) => 47 | Some( 48 | s"""|CompositeKey `${name}` should be empty or not present: 49 | |- Actual: ${repr(actualValue)} 50 | |- Expected: (empty / not present) 51 | |""".stripMargin) 52 | 53 | case (None, None) => 54 | None 55 | } 56 | case _ => 57 | Some(s"Unable to verify CompositeKey `${name}` because node $node is not a DOM Element (might be a text node?)") 58 | } 59 | } 60 | 61 | private[domtestutils] def getDomValue(node: dom.Node): Option[String] = { 62 | node match { 63 | case el: dom.Element => 64 | Option(getRawDomValue.applyOrElse(el, default = null)) 65 | case _ => 66 | None 67 | } 68 | // 69 | // val propValue = node.asInstanceOf[js.Dynamic].selectDynamic(name) 70 | // val jsUndef = js.undefined 71 | // 72 | // propValue.asInstanceOf[Any] match { 73 | // case str: String if str.length == 0 => None 74 | // case `jsUndef` => None 75 | // case null => None 76 | // case _ => Some(decode(propValue.asInstanceOf[DomV])) 77 | // } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/TestableHtmlAttr.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | 6 | class TestableHtmlAttr[V]( 7 | val name: String, 8 | val encode: V => String, 9 | val decode: String => V 10 | ) { 11 | 12 | def is(expectedValue: V): Rule = (testNode: ExpectedNode) => { 13 | testNode.addCheck(nodeAttrIs(Some(expectedValue))) 14 | } 15 | 16 | def isEmpty: Rule = (testNode: ExpectedNode) => { 17 | testNode.addCheck(nodeAttrIs(None)) 18 | } 19 | 20 | private[domtestutils] def nodeAttrIs(maybeExpectedValue: Option[V])(node: dom.Node): MaybeError = { 21 | node match { 22 | 23 | case (element: dom.html.Element) => 24 | val maybeActualValue = getAttr(element) 25 | (maybeActualValue, maybeExpectedValue) match { 26 | 27 | case (Some(actualValue), Some(expectedValue)) => 28 | if (actualValue == expectedValue) { 29 | None 30 | } else { 31 | Some(s"""|Attr `${name}` value is incorrect: 32 | |- Actual: ${repr(actualValue)} 33 | |- Expected: ${repr(expectedValue)} 34 | |""".stripMargin) 35 | } 36 | 37 | case (None, Some(expectedValue)) => 38 | if (encode(expectedValue) == null) { 39 | None // Note: `encode` returning `null` is exactly how missing attribute values are defined, e.g. in BooleanAsAttrPresenceCodec 40 | } else { 41 | Some(s"""|Attr `${name}` is missing: 42 | |- Actual: (no attribute) 43 | |- Expected: ${repr(expectedValue)} 44 | |""".stripMargin) 45 | } 46 | 47 | case (Some(actualValue), None) => 48 | Some(s"""|Attr `${name}` should not be present: 49 | |- Actual: ${repr(actualValue)} 50 | |- Expected: (no attribute) 51 | |""".stripMargin) 52 | 53 | case (None, None) => 54 | None 55 | } 56 | 57 | case _ => 58 | Some(s"Unable to verify Attr `${name}` because node $node is not a DOM HTML Element (might be a text node?)") 59 | } 60 | } 61 | 62 | private[domtestutils] def getAttr(element: dom.html.Element): Option[V] = { 63 | // Note: for boolean-as-presence attributes, this returns `None` instead of `Some(false)` when the attribute is missing. 64 | if (element.hasAttribute(name)) { 65 | Some(decode(element.getAttribute(name))) 66 | } else { 67 | None 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/TestableProp.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | 6 | import scala.scalajs.js 7 | 8 | // @TODO Create EventPropOps 9 | 10 | class TestableProp[V, DomV]( 11 | val name: String, 12 | val decode: DomV => V 13 | ) { 14 | 15 | def is(expectedValue: V): Rule = (testNode: ExpectedNode) => { 16 | testNode.addCheck(nodePropIs(maybeExpectedValue = Some(expectedValue))) 17 | } 18 | 19 | def isEmpty: Rule = (testNode: ExpectedNode) => { 20 | testNode.addCheck(nodePropIs(maybeExpectedValue = None)) 21 | } 22 | 23 | private[domtestutils] def nodePropIs(maybeExpectedValue: Option[V])(node: dom.Node): MaybeError = { 24 | val maybeActualValue = getProp(node) 25 | if (node.isInstanceOf[dom.html.Element]) { 26 | (maybeActualValue, maybeExpectedValue) match { 27 | 28 | case (Some(actualValue), Some(expectedValue)) => 29 | if (actualValue == expectedValue) { 30 | None 31 | } else { 32 | Some(s"""|Prop `${name}` value is incorrect: 33 | |- Actual: ${repr(actualValue)} 34 | |- Expected: ${repr(expectedValue)} 35 | |""".stripMargin) 36 | } 37 | 38 | case (None, Some(expectedValue)) => 39 | val rawActualValue = node.asInstanceOf[js.Dynamic].selectDynamic(name) 40 | Some(s"""|Prop `${name}` is empty or missing: 41 | |- Actual (raw): ${repr(rawActualValue)} 42 | |- Expected: ${repr(expectedValue)} 43 | |""".stripMargin) 44 | 45 | case (Some(actualValue), None) => 46 | Some(s"""|Prop `${name}` should be empty or not present: 47 | |- Actual: ${repr(actualValue)} 48 | |- Expected: (empty / not present) 49 | |""".stripMargin) 50 | 51 | case (None, None) => 52 | None 53 | } 54 | } else { 55 | Some(s"Unable to verify Prop `${name}` because node $node is not a DOM HTML Element (might be a text node?)") 56 | } 57 | } 58 | 59 | private[domtestutils] def getProp(node: dom.Node): Option[V] = { 60 | val propValue = node.asInstanceOf[js.Dynamic].selectDynamic(name) 61 | val jsUndef = js.undefined 62 | 63 | propValue.asInstanceOf[Any] match { 64 | case str: String if str.length == 0 => None 65 | case `jsUndef` => None 66 | case null => None 67 | case _ => Some(decode(propValue.asInstanceOf[DomV])) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/TestableStyleProp.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | import org.scalajs.dom.CSSStyleDeclaration 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.| 9 | 10 | class TestableStyleProp[V](val name: String) { 11 | 12 | def is(expectedValue: V | String): Rule = (testNode: ExpectedNode) => { 13 | // @TODO[Integrity] I hope this toString is ok. We don't use any fancy types for CSS anyway. 14 | testNode.addCheck(nodeStyleIs(expectedValue.toString)) 15 | } 16 | 17 | private[domtestutils] def nodeStyleIs(expectedValue: String)(node: dom.Node): MaybeError = { 18 | val actualValue = getStyle(node) 19 | 20 | 21 | if (actualValue == expectedValue) { 22 | None 23 | } else { 24 | if (actualValue.nonEmpty) { 25 | Some( 26 | s"""|Style `${name}` value is incorrect: 27 | |- Actual: ${repr(actualValue)} 28 | |- Expected: ${repr(expectedValue)} 29 | |""".stripMargin 30 | ) 31 | } else { 32 | Some( 33 | s"""|Style `${name}` is missing: 34 | |- Actual: (style missing, or empty string) 35 | |- Expected: ${repr(expectedValue)} 36 | |""".stripMargin 37 | ) 38 | } 39 | } 40 | } 41 | 42 | private[domtestutils] def getStyle(node: dom.Node): String = { 43 | // Sadly this is the best we can do because can't detect if SVGStylable is 44 | // Note: this returns an empty string even for styles that aren't set. 45 | // Not sure if we can do better... 46 | node.asInstanceOf[js.Dynamic] 47 | .selectDynamic("style") 48 | .asInstanceOf[js.UndefOr[CSSStyleDeclaration]] 49 | .flatMap { css => 50 | css.getPropertyValue(name) 51 | } 52 | .toOption 53 | .getOrElse("") 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/TestableSvgAttr.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.matching 2 | 3 | import com.raquo.domtestutils.Utils.repr 4 | import org.scalajs.dom 5 | 6 | class TestableSvgAttr[V]( 7 | val name: String, 8 | val encode: V => String, 9 | val decode: String => V, 10 | val namespace: Option[String] 11 | ) { 12 | 13 | def is(expectedValue: V): Rule = (testNode: ExpectedNode) => { 14 | testNode.addCheck(nodeSvgAttrIs(Some(expectedValue))) 15 | } 16 | 17 | def isEmpty: Rule = (testNode: ExpectedNode) => { 18 | testNode.addCheck(nodeSvgAttrIs(None)) 19 | } 20 | 21 | private[domtestutils] def nodeSvgAttrIs(maybeExpectedValue: Option[V])(node: dom.Node): MaybeError = { 22 | node match { 23 | 24 | case (element: dom.svg.Element) => 25 | val maybeActualValue = getSvgAttr(element) 26 | (maybeActualValue, maybeExpectedValue) match { 27 | 28 | case (Some(actualValue), Some(expectedValue)) => 29 | if (actualValue == expectedValue) { 30 | None 31 | } else { 32 | Some(s"""|SVG Attr `${name}` value is incorrect: 33 | |- Actual: ${repr(actualValue)} 34 | |- Expected: ${repr(expectedValue)} 35 | |""".stripMargin) 36 | } 37 | 38 | case (None, Some(expectedValue)) => 39 | if (encode(expectedValue) == null) { 40 | None // Note: `encode` returning `null` is exactly how missing attribute values are defined, e.g. in BooleanAsAttrPresenceCodec 41 | } else { 42 | Some(s"""|SVG Attr `${name}` is missing: 43 | |- Actual: (no attribute) 44 | |- Expected: ${repr(expectedValue)} 45 | |""".stripMargin) 46 | } 47 | 48 | case (Some(actualValue), None) => 49 | Some(s"""|SVG Attr `${name}` should not be present: 50 | |- Actual: ${repr(actualValue)} 51 | |- Expected: (no attribute) 52 | |""".stripMargin) 53 | 54 | case (None, None) => 55 | None 56 | } 57 | 58 | case _ => 59 | Some(s"Unable to verify SVG Attr `${name}` because node $node is not a DOM SVG Element (might be a text node?)") 60 | } 61 | } 62 | 63 | private[domtestutils] def getSvgAttr(element: dom.Element): Option[V] = { 64 | // Note: for boolean-as-presence attributes, this returns `None` instead of `Some(false)` when the attribute is missing. 65 | if (element.hasAttributeNS(namespaceURI = namespace.orNull, localName = localName)) { 66 | Some(decode(element.getAttributeNS(namespaceURI = namespace.orNull, localName = localName))) 67 | } else { 68 | None 69 | } 70 | } 71 | 72 | private[domtestutils] def localName: String = { 73 | val nsPrefixLength = name.indexOf(':') 74 | if (nsPrefixLength > -1) { 75 | name.substring(nsPrefixLength + 1) 76 | } else name 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/matching/package.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import org.scalajs.dom 4 | 5 | package object matching { 6 | 7 | trait Rule { 8 | // Don't name this `apply`. It would compete with the public ExpectedNode.apply syntax. 9 | // This method is only used internally so a lame name is ok 10 | def applyTo(node: ExpectedNode): Unit 11 | } 12 | 13 | type MaybeError = Option[String] 14 | 15 | type ErrorList = List[String] 16 | 17 | type Check = dom.Node => MaybeError 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/scalatest/DomEnvSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import com.raquo.domtestutils.{EventSimulator, Utils} 4 | import org.scalajs.dom 5 | import org.scalatest.funspec.AnyFunSpec 6 | 7 | /** 8 | * Sanity checks on the testing environment. 9 | * This does not use this library at all. 10 | */ 11 | class DomEnvSpec extends AnyFunSpec with EventSimulator with Utils { 12 | 13 | it("renders elements with attributes") { 14 | val spanId = randomString("spanId_") 15 | val span = dom.document.createElement("span") 16 | span.setAttribute("id", spanId) 17 | dom.document.body.appendChild(span) 18 | 19 | assertResult(expected = spanId)(actual = span.id) 20 | } 21 | 22 | it("handles click events") { 23 | var callbackCount = 0 24 | 25 | def testEvent(ev: dom.MouseEvent): Unit = { 26 | callbackCount += 1 27 | } 28 | 29 | val div = dom.document.createElement("div").asInstanceOf[dom.html.Div] 30 | val div2 = dom.document.createElement("div").asInstanceOf[dom.html.Div] 31 | val span = dom.document.createElement("span").asInstanceOf[dom.html.Span] 32 | 33 | div.addEventListener[dom.MouseEvent]("click", testEvent _) 34 | 35 | div.appendChild(span) 36 | dom.document.body.appendChild(div) 37 | dom.document.body.appendChild(div2) 38 | 39 | // Direct hit 40 | div.click() 41 | assertResult(expected = 1)(actual = callbackCount) 42 | 43 | // Click event should bubble up 44 | span.click() 45 | assertResult(expected = 2)(actual = callbackCount) 46 | 47 | // Click should not be counted on unrelated div 48 | div2.click() 49 | assertResult(expected = 2)(actual = callbackCount) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/scalatest/Matchers.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import org.scalactic.{Prettifier, source} 4 | import org.scalatest.matchers.should 5 | import org.scalatest.{Assertion, Assertions} 6 | 7 | trait Matchers { this: Assertions => 8 | 9 | val raw: should.Matchers = new should.Matchers {} 10 | 11 | def assertEquals( 12 | actual: scala.Any, 13 | expected: scala.Any 14 | )( 15 | implicit prettifier: org.scalactic.Prettifier, 16 | pos: org.scalactic.source.Position 17 | ): Assertion = { 18 | assertResult(expected = expected)(actual = actual) 19 | } 20 | 21 | def assertEquals( 22 | actual: scala.Any, 23 | expected: scala.Any, 24 | clue: scala.Any 25 | )( 26 | implicit prettifier: Prettifier, 27 | pos: source.Position 28 | ): Assertion = { 29 | assertResult(expected = expected, clue = clue)(actual = actual) 30 | } 31 | 32 | implicit def withShouldSyntax[A](value: A): ShouldSyntax[A] = new ShouldSyntax[A](value) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/domtestutils/scalatest/ShouldSyntax.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.scalatest 2 | 3 | import org.scalactic.{Prettifier, source} 4 | import org.scalatest.Assertion 5 | import org.scalatest.enablers.Emptiness 6 | import org.scalatest.matchers.should 7 | 8 | class ShouldSyntax[A](val actual: A) extends AnyVal { 9 | 10 | def shouldEqual( 11 | expected : scala.Any 12 | )( 13 | implicit equality : org.scalactic.Equality[A], 14 | pos : source.Position, 15 | prettifier : Prettifier 16 | ): Assertion = { 17 | ShouldSyntax.shouldEqual(actual, expected)(equality, pos, prettifier) 18 | } 19 | 20 | def shouldBe( 21 | expected: scala.Any 22 | )( 23 | implicit pos: source.Position, 24 | prettifier: Prettifier 25 | ): Assertion = { 26 | ShouldSyntax.shouldBe(actual, expected)(pos, prettifier) 27 | } 28 | 29 | def shouldNotBe( 30 | expected: scala.Any 31 | )( 32 | implicit pos: source.Position, 33 | prettifier: Prettifier 34 | ): Assertion = { 35 | ShouldSyntax.shouldNotBe(actual, expected)(pos, prettifier) 36 | } 37 | 38 | def shouldBeEmpty( 39 | implicit pos: source.Position, 40 | prettifier: Prettifier, 41 | emptiness: Emptiness[A] 42 | ): Assertion = { 43 | ShouldSyntax.shouldBeEmpty(actual)(pos, prettifier, emptiness) 44 | } 45 | 46 | def shouldNotBeEmpty( 47 | implicit pos: source.Position, 48 | prettifier: Prettifier, 49 | emptiness: Emptiness[A] 50 | ): Assertion = { 51 | ShouldSyntax.shouldNotBeEmpty(actual)(pos, prettifier, emptiness) 52 | } 53 | } 54 | 55 | object ShouldSyntax extends should.Matchers { 56 | 57 | // #Note ScalaTest generates different code for Scala 2 and Scala 3. 58 | // In particular, it does not emit convertToAnyShouldWrapper in Scala 3. 59 | // I don't care enough to figure out what or why. 60 | // If you do, see SKIP-DOTTY-START and SKIP-DOTTY-STOP in ScalaTest code and go from there. 61 | 62 | def shouldEqual[A]( 63 | actual: A, 64 | expected: scala.Any 65 | )( 66 | implicit equality : org.scalactic.Equality[A], 67 | pos : source.Position, 68 | prettifier : Prettifier 69 | ): Assertion = { 70 | actual shouldEqual expected 71 | } 72 | 73 | def shouldBe[A]( 74 | actual: A, 75 | expected: scala.Any 76 | )( 77 | implicit pos: source.Position, 78 | prettifier: Prettifier 79 | ): Assertion = { 80 | actual shouldBe expected 81 | } 82 | 83 | def shouldNotBe[A]( 84 | actual: A, 85 | expected: scala.Any 86 | )( 87 | implicit pos: source.Position, 88 | prettifier: Prettifier 89 | ): Assertion = { 90 | actual shouldNot be(expected) 91 | } 92 | 93 | def shouldBeEmpty[A]( 94 | actual: A 95 | )( 96 | implicit pos: source.Position, 97 | prettifier: Prettifier, 98 | emptiness: Emptiness[A] 99 | ): Assertion = { 100 | actual shouldBe empty 101 | } 102 | 103 | def shouldNotBeEmpty[A]( 104 | actual: A 105 | )( 106 | implicit pos: source.Position, 107 | prettifier: Prettifier, 108 | emptiness: Emptiness[A] 109 | ): Assertion = { 110 | actual should not be empty 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/TestableCompositeKeySpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.fixtures.CompositeHtmlKey 4 | import org.scalajs.dom 5 | 6 | class TestableCompositeKeySpec extends UnitSpec { 7 | 8 | val cls = new CompositeHtmlKey("class", separator = " ") 9 | 10 | def appendKey[V](el: dom.Element, key: CompositeHtmlKey, value: String): Unit = { 11 | val currentDomValue = key.getDomValue(el) 12 | currentDomValue match { 13 | case Some(domValue) => el.setAttribute(key.name, domValue + " " + value) 14 | case None => el.setAttribute(key.name, value) 15 | } 16 | } 17 | 18 | def removeAttr(el: dom.Element, attr: CompositeHtmlKey): Unit = { 19 | el.removeAttribute(attr.name) 20 | } 21 | 22 | it("cls") { 23 | val el = dom.document.createElement("span") 24 | 25 | (cls nodeKeyIs None) (el) shouldBe None 26 | (cls nodeKeyIs Some("foo")) (el) shouldBe Some("CompositeKey `class` is empty or missing:\n- Actual (raw): null\n- Expected: \"foo\"\n") 27 | 28 | appendKey(el, cls, "foo") 29 | (cls nodeKeyIs Some("foo")) (el) shouldBe None 30 | (cls nodeKeyIs Some("bar")) (el) shouldBe Some("CompositeKey `class` value is incorrect:\n- Actual: \"foo\"\n- Expected: \"bar\"\n") 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/TestableHtmlAttrSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.codecs.{BooleanAsAttrPresenceCodec, BooleanAsTrueFalseStringCodec, IntAsStringCodec, StringAsIsCodec} 4 | import com.raquo.domtestutils.fixtures.HtmlAttr 5 | import org.scalajs.dom 6 | 7 | class TestableHtmlAttrSpec extends UnitSpec { 8 | 9 | val href = new HtmlAttr("href", StringAsIsCodec) 10 | val tabIndex = new HtmlAttr("tabindex", IntAsStringCodec) 11 | val disabled = new HtmlAttr("disabled", BooleanAsAttrPresenceCodec) 12 | val contentEditable = new HtmlAttr("contenteditable", BooleanAsTrueFalseStringCodec) 13 | 14 | def setAttr[V](el: dom.Element, attr: HtmlAttr[V], value: V): Unit = { 15 | val domValue = attr.codec.encode(value) 16 | if (domValue == null) { 17 | el.removeAttribute(attr.name) 18 | } else { 19 | el.setAttribute(attr.name, domValue) 20 | } 21 | } 22 | 23 | it("href: standard string attr") { 24 | val el = dom.document.createElement("a") 25 | 26 | (href nodeAttrIs None) (el) shouldBe None 27 | (href nodeAttrIs Some("http://example.com")) (el) shouldBe Some("Attr `href` is missing:\n- Actual: (no attribute)\n- Expected: \"http://example.com\"\n") 28 | 29 | setAttr(el, href, "http://example.com") 30 | (href nodeAttrIs Some("http://example.com")) (el) shouldBe None 31 | (href nodeAttrIs Some("http://expected.com")) (el) shouldBe Some("Attr `href` value is incorrect:\n- Actual: \"http://example.com\"\n- Expected: \"http://expected.com\"\n") 32 | } 33 | 34 | it("tabIndex: integer attr") { 35 | val el = dom.document.createElement("a") 36 | 37 | (tabIndex nodeAttrIs None) (el) shouldBe None 38 | (tabIndex nodeAttrIs Some(5)) (el) shouldBe Some("Attr `tabindex` is missing:\n- Actual: (no attribute)\n- Expected: 5\n") 39 | 40 | setAttr(el, tabIndex, 10) 41 | (tabIndex nodeAttrIs Some(10)) (el) shouldBe None 42 | (tabIndex nodeAttrIs Some(5)) (el) shouldBe Some("Attr `tabindex` value is incorrect:\n- Actual: 10\n- Expected: 5\n") 43 | } 44 | 45 | it("disabled: boolean-as-presence (absence is the same as false)") { 46 | val el = dom.document.createElement("a") 47 | 48 | (disabled nodeAttrIs Some(false)) (el) shouldBe None 49 | (disabled nodeAttrIs None) (el) shouldBe None 50 | (disabled nodeAttrIs Some(true)) (el) shouldBe Some("Attr `disabled` is missing:\n- Actual: (no attribute)\n- Expected: true\n") 51 | 52 | setAttr(el, disabled, true) 53 | (disabled nodeAttrIs Some(true)) (el) shouldBe None 54 | (disabled nodeAttrIs Some(false)) (el) shouldBe Some("Attr `disabled` value is incorrect:\n- Actual: true\n- Expected: false\n") 55 | (disabled nodeAttrIs None) (el) shouldBe Some("Attr `disabled` should not be present:\n- Actual: true\n- Expected: (no attribute)\n") 56 | 57 | setAttr(el, disabled, false) 58 | (disabled nodeAttrIs Some(false)) (el) shouldBe None 59 | (disabled nodeAttrIs None) (el) shouldBe None 60 | (disabled nodeAttrIs Some(true)) (el) shouldBe Some("Attr `disabled` is missing:\n- Actual: (no attribute)\n- Expected: true\n") 61 | } 62 | 63 | it("contentEditable: boolean as true/false string attr") { 64 | val el = dom.document.createElement("a") 65 | 66 | (contentEditable nodeAttrIs Some(false))(el) shouldBe Some("Attr `contenteditable` is missing:\n- Actual: (no attribute)\n- Expected: false\n") 67 | (contentEditable nodeAttrIs None)(el) shouldBe None 68 | (contentEditable nodeAttrIs Some(true))(el) shouldBe Some("Attr `contenteditable` is missing:\n- Actual: (no attribute)\n- Expected: true\n") 69 | 70 | setAttr(el, contentEditable, true) 71 | (contentEditable nodeAttrIs Some(true))(el) shouldBe None 72 | (contentEditable nodeAttrIs Some(false))(el) shouldBe Some("Attr `contenteditable` value is incorrect:\n- Actual: true\n- Expected: false\n") 73 | (contentEditable nodeAttrIs None)(el) shouldBe Some("Attr `contenteditable` should not be present:\n- Actual: true\n- Expected: (no attribute)\n") 74 | 75 | setAttr(el, contentEditable, false) 76 | (contentEditable nodeAttrIs Some(false))(el) shouldBe None 77 | (contentEditable nodeAttrIs None)(el) shouldBe Some("Attr `contenteditable` should not be present:\n- Actual: false\n- Expected: (no attribute)\n") 78 | (contentEditable nodeAttrIs Some(true))(el) shouldBe Some("Attr `contenteditable` value is incorrect:\n- Actual: false\n- Expected: true\n") 79 | 80 | el.removeAttribute(contentEditable.name) 81 | (contentEditable nodeAttrIs Some(false))(el) shouldBe Some("Attr `contenteditable` is missing:\n- Actual: (no attribute)\n- Expected: false\n") 82 | (contentEditable nodeAttrIs None)(el) shouldBe None 83 | (contentEditable nodeAttrIs Some(true))(el) shouldBe Some("Attr `contenteditable` is missing:\n- Actual: (no attribute)\n- Expected: true\n") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/TestablePropSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.codecs.{BooleanAsIsCodec, IntAsIsCodec, IterableAsSpaceSeparatedStringCodec, StringAsIsCodec} 4 | import com.raquo.domtestutils.fixtures.Prop 5 | import org.scalajs.dom 6 | 7 | import scala.scalajs.js 8 | 9 | class TestablePropSpec extends UnitSpec { 10 | 11 | val href = new Prop("href", StringAsIsCodec) 12 | val tabIndex = new Prop("tabIndex", IntAsIsCodec) 13 | val disabled = new Prop("disabled", BooleanAsIsCodec) 14 | val classNames = new Prop("className", IterableAsSpaceSeparatedStringCodec) 15 | 16 | def setProp[V, DomV](el: dom.Element, prop: Prop[V, DomV], value: V): Unit = { 17 | val domValue = prop.codec.encode(value).asInstanceOf[js.Any] 18 | el.asInstanceOf[js.Dynamic].updateDynamic(prop.name)(domValue) 19 | } 20 | 21 | it("href: standard string prop") { 22 | val el = dom.document.createElement("a").asInstanceOf[dom.html.Anchor] 23 | 24 | (href nodePropIs None) (el) shouldBe None 25 | (href nodePropIs Some("http://example.com")) (el) shouldBe Some("Prop `href` is empty or missing:\n- Actual (raw): \"\"\n- Expected: \"http://example.com\"\n") 26 | 27 | setProp(el, href, "http://example.com") 28 | // Notice that a slash "/" was added to the URL by the "browser" (well, jsdom in this case) 29 | (href nodePropIs Some("http://example.com/")) (el) shouldBe None 30 | (href nodePropIs Some("http://expected.com/")) (el) shouldBe Some("Prop `href` value is incorrect:\n- Actual: \"http://example.com/\"\n- Expected: \"http://expected.com/\"\n") 31 | } 32 | 33 | it("tabIndex: integer prop") { 34 | val el = dom.document.createElement("a") 35 | 36 | // 0 is the default value of tabIndex 37 | (tabIndex nodePropIs Some(0)) (el) shouldBe None 38 | (tabIndex nodePropIs Some(5)) (el) shouldBe Some("Prop `tabIndex` value is incorrect:\n- Actual: 0\n- Expected: 5\n") 39 | 40 | setProp(el, tabIndex, 10) 41 | (tabIndex nodePropIs Some(10)) (el) shouldBe None 42 | (tabIndex nodePropIs Some(5)) (el) shouldBe Some("Prop `tabIndex` value is incorrect:\n- Actual: 10\n- Expected: 5\n") 43 | } 44 | 45 | it("disabled: boolean prop") { 46 | val el = dom.document.createElement("a") 47 | 48 | (disabled nodePropIs Some(false)) (el) shouldBe Some("Prop `disabled` is empty or missing:\n- Actual (raw): undefined\n- Expected: false\n") 49 | (disabled nodePropIs None) (el) shouldBe None 50 | (disabled nodePropIs Some(true)) (el) shouldBe Some("Prop `disabled` is empty or missing:\n- Actual (raw): undefined\n- Expected: true\n") 51 | 52 | setProp(el, disabled, true) 53 | (disabled nodePropIs Some(true)) (el) shouldBe None 54 | (disabled nodePropIs Some(false)) (el) shouldBe Some("Prop `disabled` value is incorrect:\n- Actual: true\n- Expected: false\n") 55 | (disabled nodePropIs None) (el) shouldBe Some("Prop `disabled` should be empty or not present:\n- Actual: true\n- Expected: (empty / not present)\n") 56 | 57 | setProp(el, disabled, false) 58 | (disabled nodePropIs Some(false)) (el) shouldBe None 59 | (disabled nodePropIs None) (el) shouldBe Some("Prop `disabled` should be empty or not present:\n- Actual: false\n- Expected: (empty / not present)\n") 60 | (disabled nodePropIs Some(true)) (el) shouldBe Some("Prop `disabled` value is incorrect:\n- Actual: false\n- Expected: true\n") 61 | } 62 | 63 | it("classNames: list as string prop") { 64 | val el = dom.document.createElement("a") 65 | 66 | (classNames nodePropIs None)(el) shouldBe None 67 | (classNames nodePropIs Some(Seq("foo", "bar")))(el) shouldBe Some("Prop `className` is empty or missing:\n- Actual (raw): \"\"\n- Expected: List(foo, bar)\n") 68 | 69 | setProp(el, classNames, Seq("foo", "bar")) 70 | (classNames nodePropIs Some(List("foo", "bar")))(el) shouldBe None 71 | // @TODO[Elegance] Scala 2.13 has ArraySeq instead of WrappedArray, so we're making an ugly replace here. 72 | (classNames nodePropIs Some(List("foo", "bar", "baz")))(el).map(_.replace("WrappedArray", "ArraySeq")) shouldBe Some("Prop `className` value is incorrect:\n- Actual: ArraySeq(foo, bar)\n- Expected: List(foo, bar, baz)\n") 73 | (classNames nodePropIs None)(el).map(_.replace("WrappedArray", "ArraySeq")) shouldBe Some("Prop `className` should be empty or not present:\n- Actual: ArraySeq(foo, bar)\n- Expected: (empty / not present)\n") 74 | 75 | setProp(el, classNames, Seq()) 76 | (classNames nodePropIs None)(el) shouldBe None 77 | (classNames nodePropIs Some(List("foo", "bar")))(el) shouldBe Some("Prop `className` is empty or missing:\n- Actual (raw): \"\"\n- Expected: List(foo, bar)\n") 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/TestableStyleSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.fixtures.StyleProp 4 | import org.scalajs.dom 5 | import org.scalajs.dom.CSSStyleDeclaration 6 | 7 | import scala.scalajs.js 8 | 9 | class TestableStyleSpec extends UnitSpec { 10 | 11 | val backgroundColor = new StyleProp[String]("background-color") 12 | val zIndex = new StyleProp[Int]("z-index") 13 | 14 | def setStyle[V](el: dom.Element, style: StyleProp[V], value: V): Unit = { 15 | el.asInstanceOf[js.Dynamic] 16 | .selectDynamic("style") 17 | .asInstanceOf[js.UndefOr[CSSStyleDeclaration]] 18 | .foreach { css => 19 | css.setProperty(style.name, value.toString) 20 | } 21 | } 22 | 23 | it("background-color: string style") { 24 | val el = dom.document.createElement("div").asInstanceOf[dom.html.Div] 25 | 26 | print("------") 27 | (backgroundColor nodeStyleIs "") (el) shouldBe None 28 | (backgroundColor nodeStyleIs "red") (el) shouldBe Some( 29 | s"""|Style `background-color` is missing: 30 | |- Actual: (style missing, or empty string) 31 | |- Expected: "red" 32 | |""".stripMargin 33 | ) 34 | 35 | setStyle(el, backgroundColor, "green") 36 | 37 | (backgroundColor nodeStyleIs "green") (el) shouldBe None 38 | (backgroundColor nodeStyleIs "red") (el) shouldBe Some( 39 | s"""|Style `background-color` value is incorrect: 40 | |- Actual: "green" 41 | |- Expected: "red" 42 | |""".stripMargin 43 | ) 44 | } 45 | 46 | it("z-index: int style") { 47 | val el = dom.document.createElement("div").asInstanceOf[dom.html.Div] 48 | 49 | (zIndex nodeStyleIs "") (el) shouldBe None 50 | (zIndex nodeStyleIs "100") (el) shouldBe Some( 51 | s"""|Style `z-index` is missing: 52 | |- Actual: (style missing, or empty string) 53 | |- Expected: "100" 54 | |""".stripMargin 55 | ) 56 | 57 | setStyle(el, zIndex, 100) 58 | 59 | (zIndex nodeStyleIs "100") (el) shouldBe None 60 | (zIndex nodeStyleIs "200") (el) shouldBe Some( 61 | s"""|Style `z-index` value is incorrect: 62 | |- Actual: "100" 63 | |- Expected: "200" 64 | |""".stripMargin 65 | ) 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/TestableSvgAttrSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.codecs.StringAsIsCodec 4 | import com.raquo.domtestutils.fixtures.SvgAttr 5 | import org.scalajs.dom 6 | 7 | class TestableSvgAttrSpec extends UnitSpec { 8 | 9 | val svgNamespaceUri = "http://www.w3.org/2000/svg" 10 | 11 | val cls = new SvgAttr("className", StringAsIsCodec, namespace = None) 12 | 13 | val xlinkHref = new SvgAttr("xlink:href", StringAsIsCodec, namespace = Some("http://www.w3.org/1999/xlink")) 14 | 15 | def setAttr[V](el: dom.Element, svgAttr: SvgAttr[V], value: V): Unit = { 16 | val domValue = svgAttr.codec.encode(value) 17 | if (domValue == null) { 18 | el.removeAttributeNS(svgAttr.namespace.orNull, localName = svgAttr.localName) 19 | } else { 20 | el.setAttributeNS(svgAttr.namespace.orNull, qualifiedName = svgAttr.name, domValue) 21 | } 22 | } 23 | 24 | it("cls: standard string attr") { 25 | val el = dom.document.createElementNS(svgNamespaceUri, "svg") 26 | 27 | (cls nodeSvgAttrIs None) (el) shouldBe None 28 | (cls nodeSvgAttrIs Some("class1")) (el) shouldBe Some("SVG Attr `className` is missing:\n- Actual: (no attribute)\n- Expected: \"class1\"\n") 29 | 30 | setAttr(el, cls, "class1") 31 | (cls nodeSvgAttrIs Some("class1")) (el) shouldBe None 32 | (cls nodeSvgAttrIs Some("class2")) (el) shouldBe Some("SVG Attr `className` value is incorrect:\n- Actual: \"class1\"\n- Expected: \"class2\"\n") 33 | } 34 | 35 | it("xlinkHref: namespaced string attr") { 36 | val el = dom.document.createElementNS(svgNamespaceUri, "svg") 37 | 38 | (xlinkHref nodeSvgAttrIs None) (el) shouldBe None 39 | (xlinkHref nodeSvgAttrIs Some("http://example.com/1")) (el) shouldBe Some("SVG Attr `xlink:href` is missing:\n- Actual: (no attribute)\n- Expected: \"http://example.com/1\"\n") 40 | 41 | setAttr(el, xlinkHref, "http://example.com/1") 42 | (xlinkHref nodeSvgAttrIs Some("http://example.com/1")) (el) shouldBe None 43 | (xlinkHref nodeSvgAttrIs Some("http://example.com/2")) (el) shouldBe Some("SVG Attr `xlink:href` value is incorrect:\n- Actual: \"http://example.com/1\"\n- Expected: \"http://example.com/2\"\n") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/UnitSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils 2 | 3 | import com.raquo.domtestutils.fixtures.{Comment, CompositeHtmlKey, CompositeSvgKey, HtmlAttr, Prop, StyleProp, SvgAttr, Tag} 4 | import com.raquo.domtestutils.matching.{ExpectedNode, RuleImplicits, TestableCompositeKey, TestableHtmlAttr, TestableProp, TestableStyleProp, TestableSvgAttr} 5 | import com.raquo.domtestutils.scalatest.Matchers 6 | import org.scalajs.dom 7 | import org.scalatest.funspec.AnyFunSpec 8 | 9 | class UnitSpec extends AnyFunSpec with Matchers with RuleImplicits[Tag[Any], Comment, Prop, HtmlAttr, SvgAttr, StyleProp, CompositeHtmlKey, CompositeSvgKey] { 10 | 11 | override implicit def makeTagTestable(tag: Tag[Any]): ExpectedNode = { 12 | ExpectedNode.element(tag.name) 13 | } 14 | 15 | override implicit def makeCommentBuilderTestable(commentBuilder: () => Comment): ExpectedNode = { 16 | ExpectedNode.comment 17 | } 18 | 19 | override implicit def makeAttrTestable[V](attr: HtmlAttr[V]): TestableHtmlAttr[V] = { 20 | new TestableHtmlAttr[V](attr.name, attr.codec.encode, attr.codec.decode) 21 | } 22 | 23 | override implicit def makePropTestable[V, DomV](prop: Prop[V, DomV]): TestableProp[V, DomV] = { 24 | new TestableProp[V, DomV](prop.name, prop.codec.decode) 25 | } 26 | 27 | override implicit def makeStyleTestable[V](style: StyleProp[V]): TestableStyleProp[V] = { 28 | new TestableStyleProp[V](style.name) 29 | } 30 | 31 | override implicit def makeSvgAttrTestable[V](svgAttr: SvgAttr[V]): TestableSvgAttr[V] = { 32 | new TestableSvgAttr[V](svgAttr.name, svgAttr.codec.encode, svgAttr.codec.decode, svgAttr.namespace) 33 | } 34 | 35 | override implicit def makeCompositeHtmlKeyTestable(key: CompositeHtmlKey): TestableCompositeKey = { 36 | new TestableCompositeKey(key.name, key.separator, getRawDomValue = { 37 | case htmlEl: dom.html.Element => htmlEl.getAttribute(key.name) 38 | }) 39 | } 40 | 41 | override implicit def makeCompositeSvgKeyTestable(key: CompositeSvgKey): TestableCompositeKey = { 42 | new TestableCompositeKey(key.name, key.separator, getRawDomValue = { 43 | case svgEl: dom.svg.Element => svgEl.getAttributeNS(namespaceURI = null, key.name) 44 | }) 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/fixtures/Comment.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.fixtures 2 | 3 | class Comment {} 4 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/fixtures/SimpleKey.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.fixtures 2 | 3 | import com.raquo.domtestutils.codecs.Codec 4 | 5 | trait SimpleKey { 6 | val name: String 7 | } 8 | 9 | class Prop[V, DomV]( 10 | override val name: String, 11 | val codec: Codec[V, DomV] 12 | ) extends SimpleKey 13 | 14 | class HtmlAttr[V]( 15 | override val name: String, 16 | val codec: Codec[V, String] 17 | ) extends SimpleKey 18 | 19 | class SvgAttr[V]( 20 | override val name: String, 21 | val codec: Codec[V, String], 22 | val namespace: Option[String] 23 | ) extends SimpleKey 24 | 25 | class StyleProp[V]( 26 | override val name: String 27 | ) extends SimpleKey 28 | 29 | class CompositeHtmlKey( 30 | val name: String, 31 | val separator: String 32 | ) 33 | 34 | class CompositeSvgKey( 35 | val name: String, 36 | val separator: String 37 | ) 38 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/domtestutils/fixtures/Tag.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.domtestutils.fixtures 2 | 3 | class Tag[+Element]( 4 | val name: String, 5 | val void: Boolean 6 | ) 7 | --------------------------------------------------------------------------------