ZIO-first Playwright client for Scala 3, plus a Scala.js DOM helper / JSEnv path for real-browser component tests. Named for Chekhov's gun: if the UI shows a control, a test should be able to fire it.
Status: early / pre-1.0. Published under early-semver. No
com.microsoft.playwrightJAR: Chekhov speaks Playwright's channel protocol from Scala/ZIO.
import chekhov.*
import chekhov.ziotest.ChekhovSuite
import zio.test.*
object TodoSpec extends ChekhovSuite:
def spec = suite("todo")(
test("add") {
for
page <- Chekhov.page
_ <- page.goto("/")
_ <- page.fill("input.new-todo", "milk")
_ <- page.press("input.new-todo", "Enter")
text <- page.innerText(".todo-list")
yield assertTrue(text.contains("milk"))
}
)Scala.js ascent component suite (JSEnv):
import chekhov.ascent.ChekhovAscent.withMounted
import chekhov.dom.*
import ascent.*, ascent.dsl.*
import zio.test.*
withMounted(ui) { root =>
getByTestId("inc", root).click *>
getByTestId("count", root).innerText.map(t => assertTrue(t == "1"))
}| Module | Artifact | Role |
|---|---|---|
core |
chekhov-core |
Config, errors, ZIO algebras, scoped AppServer / static serve |
protocol |
chekhov-protocol |
protocol.yml → Scala AST + zio-json codecs + pipe transport |
driver |
chekhov-driver |
Channel interpreter (PlaywrightDriver layers) |
zio-test |
chekhov-zio-test |
ChekhovSuite, multi-browser helpers |
dom |
chekhov-dom |
Scala.js in-page helpers (withRoot, waits, testid/role/CSS) |
ascent |
chekhov-ascent |
withMounted bridge for ascent UI + chekhov-dom (JSEnv) |
jsenv |
chekhov-jsenv |
Playwright-backed ChekhovJSEnv (scripts in a real browser page) |
sbt-chekhov |
sbt-chekhov |
Artifact dir / browser props + pinned chekhovInstall |
Suite stack (JVM):
libraryDependencies ++= Seq(
"rocks.earlyeffect" %% "chekhov-zio-test" % "<version>" % Test,
"rocks.earlyeffect" %% "chekhov-driver" % "<version>" % Test,
)Scala.js DOM helpers and real-browser JSEnv:
libraryDependencies += "rocks.earlyeffect" %%% "chekhov-dom" % "<version>" % Test
// project/plugins.sbt — pulls chekhov-jsenv onto the sbt classpath
addSbtPlugin("rocks.earlyeffect" % "sbt-chekhov" % "<version>")Ascent component tests (pulls chekhov-dom transitively):
libraryDependencies += "rocks.earlyeffect" %%% "chekhov-ascent" % "<version>" % Testimport chekhov.sbt.ChekhovPlugin.autoImport.*
// or: import chekhov.jsenv.ChekhovJSEnv
Test / jsEnv := chekhovJSEnv.value
// equivalent: Test / jsEnv := ChekhovJSEnv()Chekhov speaks this release's Playwright channel (currently 1.62.1). A leftover
npx CLI, Cursor MCP driver, or another project's node_modules on NODE_PATH is not
a valid substitute.
sbt chekhovInstall # needs sbt-chekhov; no package.json in the consumer
sbt 'Test/testOnly …'chekhovInstall extracts playwright@1.62.1 into the Chekhov cache and installs only
the browsers in chekhovBrowsers (default: Chromium, from chekhovBrowser). Tests then
find that pin without PLAYWRIGHT_DRIVER_CLI. ChekhovSuite runs once per listed browser.
PLAYWRIGHT_DRIVER_CLI remains an override, but the CLI's package.json version must
equal the pin. A mismatch fails before run-driver (a skew protocol looks like a
Chekhov bug). Missing browser revisions name the pin and the command to run.
There is no skip flag. If a module's test must stay cheap, put ChekhovSuites in a
separate sbt project (e2e) and leave unit tests in core. sbt core/test never
launches browsers; sbt e2e/test does.
Chekhov normally launches the browser revision that sbt chekhovInstall downloaded for the
pinned Playwright version. To run against a browser already on the machine (a distro package,
a managed install, or a channel like Google Chrome), point it at that instead. Three keys do
it; each reads a -Dchekhov.* system property first, then a CHEKHOV_* environment variable
(props win):
| What | System property | Environment variable |
|---|---|---|
| Browser binary | chekhov.executablePath |
CHEKHOV_EXECUTABLE_PATH |
| Installed channel (Chromium only) | chekhov.channel |
CHEKHOV_CHANNEL |
| Extra process args | chekhov.launchArgs |
CHEKHOV_LAUNCH_ARGS |
Setting either executablePath or channel skips the pinned-browser-revision check. The
Playwright driver CLI is still required and must match the pin, so keep running
sbt chekhovInstall; only the downloaded browser binary is bypassed.
# A system Chromium with no sandbox (NixOS, CI containers). Env vars are inherited by the
# forked test JVM, so this needs no build change:
CHEKHOV_EXECUTABLE_PATH=/usr/bin/chromium CHEKHOV_LAUNCH_ARGS="--no-sandbox" sbt e2e/testFull
# An installed channel instead of a binary path (Chromium family only):
CHEKHOV_CHANNEL=chrome sbt e2e/testFulllaunchArgs is a flag list: comma or whitespace separates arguments, and an argument that
takes a value uses --flag=value. So "--no-sandbox --disable-gpu" becomes two args. A value
containing a space cannot be expressed; use the = form or a file-based option instead.
The sbt plugin forwards browser, browsers, headless, and artifactsDir to the test JVM
but not these three keys. Set them as environment variables, add -Dchekhov.channel=chrome
(and friends) to Test / javaOptions, or override in code:
class MySuite extends ChekhovSuite {
override def chekhovConfig: ChekhovConfig =
super.chekhovConfig.copy(
executablePath = Some("/usr/bin/chromium"),
launchArgs = List("--no-sandbox"),
)
}chekhov.channel is Chromium-only. Setting it while chekhov.browser is Firefox or WebKit
fails fast at launch with a message naming the keys, rather than surfacing as an opaque
Playwright protocol error.
Default artifactsDir is target/chekhov (override with -Dchekhov.artifactsDir / CHEKHOV_ARTIFACTS_DIR):
| Path | Contents |
|---|---|
failures/ |
PNGs from ChekhovSuite.screenshotOnFailure |
traces/ |
Playwright trace zips when traceCapture is Always or kept OnFailure |
videos/ |
Recorded videos when videoCapture is Always or kept OnFailure |
serve/ |
Scoped serve logs |
ChekhovConfig(
traceCapture = ArtifactCapture.Always, // Off | OnFailure | Always
videoCapture = ArtifactCapture.OnFailure,
)
// For OnFailure, also: suite(...)(...) @@ ChekhovSuite.retainArtifactsOnFailureLocal browsers / E2E in this repo:
npm ci
./scripts/install-browsers.sh # or: npm run playwright:install / sbt pwInstall
sbt testFullBrowsers land in Playwright’s default OS cache (~/.cache/ms-playwright on Linux,
~/Library/Caches/ms-playwright on macOS). Consuming projects should run
sbt chekhovInstall instead of copying this script or adding playwright to a
package.json. zipx CI installs under target/ms-playwright so browsers share
the LocalDir sbt cache key.
Chekhov pins a Playwright npm version and vendors the matching channel
protocol.yml (merged from packages/protocol/spec/*.yml on modern tags). Keep
those in sync; do not edit the vendored YAML by hand.
One-liner (recommended):
sbt 'playwrightBump 1.62.1' # or: sbt 'pwBump 1.62.1'
sbt playwrightBumpLatest # or: sbt pwBumpLatest
sbt playwrightInstallBrowsers # or: sbt pwInstall (optional, after bump)playwrightBump <version> / playwrightBumpLatest will:
- Pin
devDependencies.playwrightinpackage.jsonand runnpm install - Download the protocol for that GitHub tag and write
protocol/src/main/resources/playwright/protocol.yml - Regenerate
protocol/.../generated/ProtocolMeta.scala(version + definition inventory)
It regenerates ProtocolMeta, SharedTypes, ProtocolSurface, and allowlist
Commands (param ADTs from YAML parameters:). Envelopes stay small/stable unless
the wire shape changes. After a bump:
- Skim the
protocol.yml/Commands.scaladiff for claimed-surface changes - Extend the allowlist +
PlaywrightDriverif the dogfood path needs a new method, thensbt pwCodegen sbt pwInstallif you need matching browser binaries locallysbt 'protocol/testOnly chekhov.protocol.DriverSmokeSpec' 'driver/testOnly chekhov.driver.MultiBrowserFixtureSpec'
CI / zipx: Verify runs npm ci, then ./scripts/install-browsers.sh with
PLAYWRIGHT_BROWSERS_PATH (build-wide zipxEnv, omitted from reusable-workflow
callers since zipx 0.1.3) set to target/ms-playwright so browsers land under the
LocalDir target path and share the same sbt actions/cache key (epoch +
run_id). Mid-PR pushes reuse that key. After merge, Verify is skipped;
cache-rehydrate runs the same browser extraSteps plus compile so main
saves digests and browsers for the next PR. Linux CI also caches Playwright
install-deps .debs under ~/.cache/chekhov-apt-archives (keyed on
package-lock.json) so apt mostly reuses local archives. After a pwBump,
install may fetch new browser/apt revisions once. Commit package-lock.json
with the bump. zipxWorkflowCheck is part of sbt ci so build.sbt setup
steps cannot drift from .github/workflows/ci.yml. Regenerate with
sbt zipxWorkflowGenerate only when you change zipx settings (Node version,
browser env, etc.), not on every Playwright pin bump.
Granular tasks:
| Task | Alias | What it does |
|---|---|---|
playwrightBump <ver> |
pwBump |
Full pin + vendor + ProtocolMeta + SharedTypes/Surface/Commands |
playwrightBumpLatest |
pwBumpLatest |
Same, version from npm view playwright version |
playwrightVendorProtocol |
pwVendor |
Re-vendor YAML + ProtocolMeta + SharedTypes/Surface/Commands |
playwrightRegenMeta |
ProtocolMeta only (from on-disk YAML) | |
playwrightCodegen |
pwCodegen |
SharedTypes + ProtocolSurface + allowlist Commands |
playwrightInstallBrowsers |
pwInstall |
./scripts/install-browsers.sh |
playwrightVersion |
Show the pin read from package.json |
sbt 'show playwrightVersion'Chekhov does not implement every Playwright channel method. Claimed methods live in a
curated allowlist; param ADTs are generated from protocol.yml. Do not edit
protocol/.../generated/Commands.scala field lists by hand.
When to add: a dogfood suite or hub app (e.g. mermoid needing storageState /
IndexedDB / webStorage*) needs a channel method that is not yet claimed.
How to add:
-
Confirm the method exists for the pinned protocol:
ProtocolSurface.has("<Channel>", "<method>")(aftersbt pwCodegen), or- search
protocol/src/main/resources/playwright/protocol.ymlunder that channel’scommands:
-
Append a row to
ProtocolCodegen.commandAllowlistinproject/ProtocolCodegen.scala:CommandSpec("BrowserContext", "storageState", "BrowserContextStorageState"), // ^channel ^YAML method ^Scala case class name
-
Regenerate:
sbt pwCodegen
-
Wire the interpreter in
PlaywrightDriver(guid +conn.send(..., "method", Commands.YourType(...))) and, if it is part of the public API, expose it on the ZIO algebra incore. -
Extend coverage / a small test if the method is load-bearing (storage cluster, etc.).
-
Commit the allowlist change and the regenerated
Commands.scala/ProtocolSurface.scala/SharedTypes.scalaas needed.
What stays curated (not generated from YAML fields): allowlist membership, driver
wiring, algebra surface. What is generated: case class fields + codecs for each
allowlist entry (including $mixin expansion).
Hub storage cluster: BrowserContext.storageState / setStorageState (IndexedDB via
indexedDB = true), cookies (cookies / addCookies / clearCookies), page
webStorage* (WebStorageKind.Local / Session).
Services are traits with ZLayer companions:
ChannelTransport.layer— scoped Noderun-driverpipeChannelConnection.layer— initialize + request/responsePlaywrightDriver.withBrowserType/browserLayer/pageLayer: building blocksPlaywrightDriver.processLayers: shared run-driver + one browser per spec / browser fan-outPlaywrightDriver.pageLayers: freshBrowserContext+Pageper test; concurrent tests in a suite each get their own pagePlaywrightDriver.suiteLayers: one-shot composition of process + page (whatChekhovSuiteuses)AppServer.layer(config)/StaticFileServer.layer(dir)— scoped serve + readinessChekhovConfig.layer— env /-Dchekhov.*defaults; artifacts undertarget/chekhov
Debugging: chekhovBrowserKeepOpen := true (or -Dchekhov.keepOpen=true /
CHEKHOV_KEEP_OPEN=true) skips close and parks until Enter; default false. Headed +
keep-open is the debug combo for stepping through a flaky suite.
ChekhovJSEnv runs Scala.js scripts inside a real Playwright browser (same channel
driver / browser install as the JVM client). Materializes Input.Script /
Input.ESModule onto a localhost page and bridges scalajsCom via frame
evaluateExpression.
Consumers (published artifacts): add sbt-chekhov (brings chekhov-jsenv onto the
sbt classpath) and set:
Test / jsEnv := chekhovJSEnv.value
// or: Test / jsEnv := chekhov.jsenv.ChekhovJSEnv()
// or: Test / jsEnv := ChekhovJSEnv(ChekhovBrowser.Firefox)Use ModuleKind.ESModule (or ensure scripts are materializable) for linked test
output. This repo’s jsenv-smoke / dom projects still use an internal classpath
bridge so the monorepo need not publish to exercise CI.
Live smoke (from this repo, with browsers installed):
sbt 'jsenv/testOnly chekhov.jsenv.JsEnvComSpec'
sbt jsenv-smoke/testFull
sbt dom/testFullChromium, Firefox, and WebKit are first-class. Declare the list once in sbt; install and
ChekhovSuite both honor it:
chekhovBrowsers := Seq(ChekhovBrowser.Firefox)
// or: chekhovBrowsers := Seq(ChekhovBrowser.Chromium, ChekhovBrowser.Firefox)Without the plugin: ChekhovSuite.forBrowsers(...) or -Dchekhov.browsers=firefox,chromium.
Once per clone, enable the scalafmt pre-commit hook:
./scripts/install-git-hooksFormatting is enforced (sbt scalafmtCheckAll). CI workflows are generated by
zipx (sbt zipxWorkflowGenerate after module changes).
Apache-2.0