JSEnv and chekhov-dom
JVM E2E drives the app from outside the page. JSEnv runs Scala.js tests inside a real
Playwright browser so you can assert on live DOM without js.Dynamic ceremony.
Wire ChekhovJSEnv
Published consumers: add the plugin (it depends on chekhov-jsenv) and set jsEnv:
// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-chekhov" % "<version>")
// build.sbt
import chekhov.sbt.ChekhovPlugin.autoImport.*
libraryDependencies += "rocks.earlyeffect" %%% "chekhov-dom" % "<version>" % Test
Test / jsEnv := chekhovJSEnv.value
// or: Test / jsEnv := ChekhovJSEnv()
// or: Test / jsEnv := ChekhovJSEnv(ChekhovBrowser.Firefox)
Prefer ModuleKind.ESModule (or otherwise materializable scripts) for linked test output.
ChekhovJSEnv materializes Input.Script / Input.ESModule onto a localhost page and bridges
scalajsCom via frame evaluateExpression.
This monorepo still uses an internal classpath bridge for jsenv-smoke / dom so CI need
not publish first. Consumers should not copy that bridge.
chekhov-dom helpers
import chekhov.dom.ChekhovDom.*
import zio.test.*
test("focus") {
withRoot { root =>
for
_ <- ZIO.succeed(/* mount UI under root */)
_ <- getByRole("textbox", root).click
start <- getByRole("textbox", root).selectionStart
yield assertTrue(start >= 0)
}
}
withRoot installs a scoped throwaway parent under document.body. Locators cover
testid / role / CSS; waitFor polls until the node appears. Depend on
org.scala-js:::scalajs-dom only (not ascent’s DOM facade).
Isolation: one page per module
ChekhovJSEnv hosts the whole module's compiled test bundle in one Playwright page,
shared by every spec and test in the module (ZIO Test runs them concurrently).
withRootinstalls each scope inside its own iframe, so scoped DOM work is isolated from sibling scopes. Pass the scoped root to locators (getByTestId("inc", root)), not the whole document.The parent document is still shared: whole-document assertions (element counts, global state) can observe a sibling test mid-flight and flake. Assert on your own element: capture the root inside the scope and check it after exit.
test("count starts at zero") {
withRoot { root =>
for
_ <- ZIO.succeed(mount(ui))
t <- getByTestId("count", root).innerText
yield assertTrue(t == "0")
}
}
chekhov-ascent
Ascent UI under JSEnv lives in chekhov-ascent (ChekhovAscent.withMounted), not
core. It mounts into a chekhov-dom root and tears down via ascent’s subscription bag:
libraryDependencies += "rocks.earlyeffect" %%% "chekhov-ascent" % "<version>" % Test
import chekhov.ascent.ChekhovAscent.withMounted
import chekhov.dom.*
withMounted(ui) { root =>
getByTestId("inc", root).click
}
Use chekhov-dom alone when you do not need ascent.
Smoke in this repo
sbt 'jsenv/testOnly chekhov.jsenv.JsEnvComSpec'
sbt jsenv-smoke/testFull
sbt dom/testFull
sbt ascent/testFull
ChekhovBrowser.fromString("firefox").map(_.channelName)Some(firefox)