sbt-uni-playwright Plugin
sbt-uni-playwright runs your Scala.js tests in a real browser (headless Chromium, Firefox, or WebKit) via Playwright. Unlike jsdom it supports ES modules and a faithful DOM, and Chromium matches the Electron renderer — so the same tests cover web and Electron app code.
It bundles a Scala.js JSEnv (uni-jsenv-playwright) and wires it into Test / jsEnv for you, plus a task to pre-download the browser binaries.
No Node.js required
The underlying Java Playwright runtime bundles its own driver and downloads browsers on demand, so you do not need Node.js or an npm playwright package to run the tests.
sbt 2.x / Scala.js only
The plugin targets sbt 2.x (its metabuild runs on Scala 3) and is meant for Scala.js projects. It is not published for sbt 1.x.
Installation
In project/plugins.sbt, add the Scala.js plugin and this one:
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
addSbtPlugin("org.wvlet.uni" % "sbt-uni-playwright" % "2026.1.39")UniPlaywrightPlugin is not triggered automatically, so enable it (alongside ScalaJSPlugin) on each Scala.js project that should test in a browser:
import org.scalajs.linker.interface.ModuleKind
lazy val app = project
.enablePlugins(ScalaJSPlugin, UniPlaywrightPlugin)
.settings(
// Recommended: build ES modules so modern npm/ESM packages work.
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }
)The plugin sets Test / jsEnv to a Playwright environment, so Test runs launch a real browser:
sbt> app/testsbt 2.x dependencies
On sbt 2.x, %% already encodes the Scala.js platform suffix (_sjs1), so use %% (not the old %%%) for your Scala.js library dependencies.
Testing uni-dom components
To render uni-dom components and simulate clicks, typing, and pointer drags, mix in DomTestSupport from uni-test (Scala.js), which unmounts what each test rendered:
import org.scalajs.dom
import wvlet.uni.dom.testing.*
class CounterTest extends DomTestSupport:
test("clicking +1 increments the counter"):
val m = mount(Counter())
fireEvent.click(m.getByText("+1"))
m.text shouldContain "count=1"
test("onMount wiring"):
val m = mount(AutoFocusInput())
// onMount runs a step after mount returns
settle().map { _ => dom.document.activeElement shouldBe m.get("input") }fireEvent also has pointerDown / pointerMove / pointerUp (with clientX, clientY, pointerId, pointerType) and keyDown(..., isComposing = true) for a key pressed during IME composition.
Configuration
Pick the browser and headed/headless mode with these settings:
uniPlaywrightBrowser := "chromium" // or "firefox" / "webkit"
uniPlaywrightHeadless := true| Setting | Type | Default | Description |
|---|---|---|---|
uniPlaywrightBrowser | String | "chromium" | Browser for Test / jsEnv: chromium, firefox, webkit |
uniPlaywrightHeadless | Boolean | true | Run the browser without a visible window |
uniPlaywrightBrowsers | Seq[String] | the single uniPlaywrightBrowser | Browsers that uniPlaywrightInstall downloads |
uniPlaywrightScreenshotDir | File | target.value / "uni-playwright" / "screenshots" | Where screenshot files and expectScreenshot actual/diff images are written |
uniPlaywrightSnapshotDir | File | src/test/screenshots | Golden images for expectScreenshot |
uniPlaywrightUpdateSnapshots | Boolean | false | Overwrite goldens instead of comparing (also UNI_PLAYWRIGHT_UPDATE_SNAPSHOTS=1) |
Installing browsers
Java Playwright downloads the browser on first use, but you can pre-download and pin it (handy in CI, to avoid a first-run download mid-test):
sbt> uniPlaywrightInstall| Task | Description |
|---|---|
uniPlaywrightInstall | Download and pin the configured Playwright browser(s) |
CI on Linux
On a Linux runner, install the browser's OS libraries too. Pin the npm CLI to the same Playwright version the runtime uses so the cached build matches:
pnpm dlx playwright@1.49.0 install --with-deps chromiumScreenshots and visual regression
Tests running in the browser can call JVM-side Playwright through wvlet.uni.test.playwright.Playwright (in uni-test, Scala.js only). Each call returns a lazy Rx: the command is sent when the Rx runs, so return it from the test body and chain steps with flatMap or a for comprehension:
import wvlet.uni.test.UniTest
import wvlet.uni.test.playwright.Playwright
class DialogTest extends UniTest:
test("dialog renders") {
mountDialog()
Playwright.expectScreenshot("dialog-open", selector = "#dialog")
}| Method | Description |
|---|---|
screenshot(name, selector = "", fullPage = false): Rx[String] | Write <name>.png to the screenshot dir; returns its absolute path |
expectScreenshot(name, selector = "", fullPage = false, maxDiffPixelRatio = 0.0, threshold = 0.1): Rx[Unit] | Compare with the golden <name>-<browser>-<os>.png in the snapshot dir |
isAvailable: Boolean | Whether the page runs under a compatible Playwright JSEnv |
namemay contain only letters, digits,.,_, and-.- A pixel differs when any color channel differs by more than
threshold × 255; the test fails when the differing-pixel ratio exceedsmaxDiffPixelRatioor the image size changed. On failure,<name>-actual.pngand<name>-diff.png(differences in red) are written to the screenshot dir. - A missing golden is written from the actual screenshot and the test fails, so review and commit it, then re-run.
- Golden names include the browser and OS (
linux,darwin,win32) because font rendering differs across them. Generate the goldens CI compares against on the CI OS. - To accept intended changes, run once with
uniPlaywrightUpdateSnapshots := trueorUNI_PLAYWRIGHT_UPDATE_SNAPSHOTS=1. - The viewport is fixed at 1280×720 with device scale factor 1.
Outside this JSEnv (e.g. on Node.js) the calls fail with an explanatory error; use Playwright.isAvailable to skip instead.
Trusted input and viewport
Events created with dispatchEvent have isTrusted = false and skip default actions such as moving focus, inserting text, or submitting a form on Enter. These methods drive the browser through Playwright instead, so the page gets real input:
test("submits on Enter") {
mountForm()
for
_ <- Playwright.click("#name")
_ <- Playwright.typeText("uni")
_ <- Playwright.press("Enter")
yield submitted shouldBe true
}
test("collapses the menu on narrow screens") {
mountMenu()
Playwright.withViewportSize(500, 800) {
Playwright.expectScreenshot("menu-narrow", selector = "#menu")
}
}| Method | Description |
|---|---|
click(selector, timeoutMillis = 5000): Rx[Unit] | Click the matching element |
hover(selector, timeoutMillis = 5000): Rx[Unit] | Move the mouse over the matching element (:hover applies) |
fill(selector, text, timeoutMillis = 5000): Rx[Unit] | Replace the value of an input, textarea, or contenteditable element |
typeText(text): Rx[Unit] | Type text key by key into the focused element |
press(key): Rx[Unit] | Press a key or combination on the focused element, e.g. "Enter", "Control+A" |
setViewportSize(width, height): Rx[(Int, Int)] | Resize the viewport; returns the previous size |
withViewportSize(width, height)(body: Rx[A]): Rx[A] | Run body at that size, then restore the previous size |
- Selectors are Playwright selectors (CSS by default). A selector matching more than one element fails rather than picking one.
- Element commands wait up to
timeoutMillisfor the element to be actionable (visible, stable, enabled). The default is 5s, shorter than Playwright's 30s, because a waiting command pauses the test run's console output. - All tests in a run share one page, so a viewport change persists. Prefer
withViewportSize, which keeps the 1280×720 baseline that screenshots rely on. - These commands need
uni-jsenv-playwright2026.1.27 or later; an older one fails the call with "requires a newer uni-jsenv-playwright".
Failure artifacts
On an uncaught JS error, the environment saves a screenshot and a Playwright trace under target/uni-playwright, and logs a hint to open the trace:
npx playwright show-trace target/uni-playwright/trace-<n>.zipUsing the JSEnv directly (without the plugin)
If you only want the environment and prefer to wire Test / jsEnv yourself, add the library to project/plugins.sbt:
libraryDependencies += "org.wvlet.uni" %% "uni-jsenv-playwright" % "2026.1.39"Then set the environment. sbt 2.x caches setting values and a JSEnv is not serializable, so wrap it in Def.uncached:
import wvlet.uni.jsenv.playwright.PlaywrightJSEnv
Test / jsEnv := Def.uncached(new PlaywrightJSEnv("chromium", headless = true))For finer control, construct it from a PlaywrightConfig:
import wvlet.uni.jsenv.playwright.{PlaywrightConfig, PlaywrightJSEnv}
Test / jsEnv := Def.uncached(
new PlaywrightJSEnv(
PlaywrightConfig()
.withBrowserName("webkit")
.withHeadless(true)
.noArtifactCapture
)
)PlaywrightConfig field | Type | Default | Description |
|---|---|---|---|
browserName | String | "chromium" | chromium, firefox, or webkit |
headless | Boolean | true | Run without a visible window |
captureArtifactsOnFailure | Boolean | true | Save a screenshot + trace on an uncaught JS error |
artifactDir | Path | target/uni-playwright | Where failure artifacts are written |
launchArgs | List[String] | Nil | Extra browser CLI flags appended to the defaults |
Each field has a withXxx(...) setter, plus noArtifactCapture to disable artifact capture.