diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05b5f35..ff084c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,9 +312,9 @@ jobs: - name: e2e run: | if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then - sbt 'cleanFull; e2e/chekhovInstall; e2e/Test/testFull' + sbt 'cleanFull; e2e/chekhovInstall; e2e/Test/testFull; ascentChekhovJS/Test/testFull' else - sbt 'e2e/chekhovInstall; e2e/Test/testFull' + sbt 'e2e/chekhovInstall; e2e/Test/testFull; ascentChekhovJS/Test/testFull' fi env: ZIPX_VERIFY_CLEAN_FULL: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'clean') }} @@ -353,7 +353,7 @@ jobs: env: PGP_SECRET: ${{ secrets.PGP_SECRET }} - name: publish - run: sbt 'domTypes/publishSigned; domTypesJS/publishSigned; domTypesNative/publishSigned; preview/publishSigned; sbtAscentPreview/publishSigned; core/publishSigned; coreJS/publishSigned; coreNative/publishSigned; domFacadeJS/publishSigned; conduitBridge/publishSigned; conduitBridgeJS/publishSigned; conduitBridgeNative/publishSigned; css/publishSigned; cssJS/publishSigned; cssNative/publishSigned; datastar/publishSigned; datastarJS/publishSigned; datastarNative/publishSigned; domCore/publishSigned; domCoreJS/publishSigned; domCoreNative/publishSigned; mountEngine/publishSigned; mountEngineJS/publishSigned; mountEngineNative/publishSigned; html/publishSigned; htmlJS/publishSigned; htmlNative/publishSigned; jsJS/publishSigned; datastarHttp/publishSigned; datastarJsJS/publishSigned; sonaRelease' + run: sbt 'domTypes/publishSigned; domTypesJS/publishSigned; domTypesNative/publishSigned; preview/publishSigned; sbtAscentPreview/publishSigned; core/publishSigned; coreJS/publishSigned; coreNative/publishSigned; domFacadeJS/publishSigned; ascentChekhov/publishSigned; conduitBridge/publishSigned; conduitBridgeJS/publishSigned; conduitBridgeNative/publishSigned; css/publishSigned; cssJS/publishSigned; cssNative/publishSigned; datastar/publishSigned; datastarJS/publishSigned; datastarNative/publishSigned; domCore/publishSigned; domCoreJS/publishSigned; domCoreNative/publishSigned; mountEngine/publishSigned; mountEngineJS/publishSigned; mountEngineNative/publishSigned; html/publishSigned; htmlJS/publishSigned; htmlNative/publishSigned; jsJS/publishSigned; ascentChekhovJS/publishSigned; datastarHttp/publishSigned; datastarJsJS/publishSigned; sonaRelease' docs: name: docs if: (startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch') diff --git a/README.md b/README.md index 5296f61..86c51b0 100644 --- a/README.md +++ b/README.md @@ -240,9 +240,32 @@ toggling one item doesn't rebuild the others — that's the surgical patching. sbt testJVM # JVM suites (use testFull per module on sbt 2) sbt todoConduitJS/ascentPreviewStage # splice + stage the example without a browser sbt e2e/chekhovInstall && sbt e2e/testFull # Firefox suites against splice+preview +sbt "e2e/chekhovInstall; ascentChekhovJS/testFull" # JSEnv typed handles (ascent-chekhov) ./scripts/install-git-hooks # once per clone: pre-commit runs scalafmtCheckAll ``` +Component tests (JSEnv) mount a `UI` and talk to nodes as `InputHandle` / `ButtonHandle`: + +```scala +import ascent.*, ascent.dsl.*, ascent.chekhov.AscentChekhov.withMounted + +withMounted(ui) { root => + root.button("inc").click *> + root.getByTestId("count").innerText.map(t => assertTrue(t == "1")) +} +``` + +JVM `ChekhovSuite` uses the same lattice as tagged Playwright selectors (`HtmlTag.input` keeps `fill` off a button). Chekhov's `Page.getByPlaceholder(text)` already exists, so the typed call is on `PageHandles` (not an overloaded `page.getByPlaceholder`): + +```scala +import ascent.HtmlTag +import ascent.chekhov.PageHandles + +PageHandles.getByPlaceholder(page, "Your name", HtmlTag.input).fill("Ada") +PageHandles.getByTestId(page, "inc", HtmlTag.button).click +page.button("inc").click +``` + ascent is built with Scala 3 and cross-compiled to **JVM, Scala.js, and Scala Native** via `sbt-projectmatrix`. The module layout: @@ -259,6 +282,7 @@ ascent is built with Scala 3 and cross-compiled to **JVM, Scala.js, and Scala Na | `datastar-js` | Browser datastar runtime: SSE → Squawk / DOM, action dispatch ([readme](datastar-js/README.md)) | | `datastar-http` | Server wrapper over `zio-http-datastar-sdk` ([readme](datastar-http/README.md)) | | `preview` | Static file server + SSE reload (`ascent-preview`) ([readme](preview/README.md)) | +| `chekhov` | Typed Chekhov locators (`ascent-chekhov`): JSEnv live handles + JVM `Page` selectors | | `sbt-ascent-preview` | `enablePlugins(AscentPreviewPlugin)` then `sbt ~/ascentPreview` | | `domgen` | JVM-only generator that emits the typed catalogs from W3C webref ([readme](domgen/README.md)) | | `example/*` | One self-contained splice+preview app per subdir (e.g. `todo-conduit`) | diff --git a/build.sbt b/build.sbt index d8969b5..a216eba 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,8 @@ import ascent.preview.sbt.AscentPreviewPlugin import ascent.preview.sbt.AscentPreviewPlugin.autoImport.* import ascent.preview.sbt.AscentPreviewPort +import chekhov.ChekhovBrowser +import chekhov.jsenv.ChekhovJSEnv MyVersions.settings @@ -117,6 +119,7 @@ val ascentModules = Seq( "ascent-datastar", "ascent-datastar-http", "ascent-preview", + "ascent-chekhov", ) /** Published Specular jars depend on the Maven Central `ascent-*` release, but the docs modules `dependsOn` local @@ -147,7 +150,8 @@ lazy val root = (project in file(".")) html.projectRefs ++ datastar.projectRefs ++ datastarJs.projectRefs ++ datastarHttp.projectRefs ++ datastarExample.projectRefs ++ datastarExampleServer.projectRefs ++ hybridChat.projectRefs ++ hybridChatServer.projectRefs ++ - todoConduit.projectRefs ++ docs.projectRefs ++ preview.projectRefs :+ + todoConduit.projectRefs ++ docs.projectRefs ++ preview.projectRefs ++ + ascentChekhov.projectRefs :+ LocalProject("sbtAscentPreview")) * ) .settings( @@ -625,18 +629,55 @@ def ascentPlatformTestCommand(find: ProjectMatrix => ProjectFinder): String = .sorted .mkString("; ") -addCommandAlias("testJVM", ascentPlatformTestCommand(_.jvm)) +// ascentChekhov is not in ascentMatrices: its JS row is ChekhovJSEnv (not jsdom) and must +// stay off testJS. The JVM row is browser-free selector tests and joins testJVM here. +addCommandAlias("testJVM", ascentPlatformTestCommand(_.jvm) + "; ascentChekhov/test") addCommandAlias("testJS", ascentPlatformTestCommand(_.js)) addCommandAlias("testNative", ascentPlatformTestCommand(_.native)) lazy val e2eStage = taskKey[Unit]("Stage example preview trees for Chekhov e2e") // Browser suites. Not aggregated so library `testFull` stays browser-free. +// --- ascent-chekhov : typed Chekhov locators over the HTML lattice (jvm + js). --- +// Shared sources are the TagHandle typeclass + handle algebra (no live DOM, no Chekhov +// imports). The JVM row interprets handles as Playwright selector strings via chekhov-core. +// The JS row mounts a UI into chekhov-dom's iframe withRoot and talks to live ascent.dom +// nodes. Not in ascentMatrices: JS tests need ChekhovJSEnv (see testJVM / e2e capability). +lazy val ascentChekhov = (projectMatrix in file("chekhov")) + .disablePlugins(chekhov.sbt.ChekhovPlugin) + .dependsOn(core) + .settings( + name := "ascent-chekhov", + scalacOptions ++= commonScalacOptions, + zioTestSettings, + ) + .jvmPlatform( + scalaVersions, + Nil, + (p: Project) => p.settings(MyVersions.chekhovCoreLib), + ) + .jsPlatform( + scalaVersions, + Nil, + (p: Project) => + p.dependsOn(js.js(scala3Version)) + .settings( + MyVersions.chekhovDomLib, + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule)), + // Do not enable ChekhovPlugin here: it sets Test / fork := true, which is a JVM + // suite setting. JSEnv is constructed directly (same Firefox pin as e2e). + Test / jsEnv := Def.uncached( + ChekhovJSEnv(browser = ChekhovBrowser.Firefox, headless = true, keepOpen = false) + ), + ), + ) + lazy val e2e = (project in file("e2e")) .dependsOn( preview.jvm(scala3Version), datastarExampleServer.jvm(scala3Version), hybridChatServer.jvm(scala3Version), + ascentChekhov.jvm(scala3Version), ) .settings( name := "ascent-e2e", diff --git a/chekhov/src/main/scala/ascent/chekhov/HandleBackend.scala b/chekhov/src/main/scala/ascent/chekhov/HandleBackend.scala new file mode 100644 index 0000000..d2fc644 --- /dev/null +++ b/chekhov/src/main/scala/ascent/chekhov/HandleBackend.scala @@ -0,0 +1,14 @@ +package ascent.chekhov + +import zio.Trace + +/** Effectful DOM verbs a handle needs. Shared by the JS live-node backend and the JVM Playwright selector backend. + * Implementations must not appear in this file (no Chekhov, no `ascent.dom`). + */ +trait HandleBackend[F[_]]: + def click(selector: String)(using Trace): F[Unit] + def fill(selector: String, value: String)(using Trace): F[Unit] + def press(selector: String, key: String)(using Trace): F[Unit] + def innerText(selector: String)(using Trace): F[String] + def textContent(selector: String)(using Trace): F[String] +end HandleBackend diff --git a/chekhov/src/main/scala/ascent/chekhov/Handles.scala b/chekhov/src/main/scala/ascent/chekhov/Handles.scala new file mode 100644 index 0000000..c2ed233 --- /dev/null +++ b/chekhov/src/main/scala/ascent/chekhov/Handles.scala @@ -0,0 +1,25 @@ +package ascent.chekhov + +import zio.Trace + +/** Untyped element. `fill` is deliberately absent: a button or span must not compile a fill. */ +class ElementHandle[F[_]](val selector: String, val backend: HandleBackend[F]): + def click(using Trace): F[Unit] = backend.click(selector) + def press(key: String)(using Trace): F[Unit] = backend.press(selector, key) + def innerText(using Trace): F[String] = backend.innerText(selector) + def textContent(using Trace): F[String] = backend.textContent(selector) + +/** `` — the only shared extra verb is [[fill]]. Live `value` / `checked` live on the JS row. */ +final class InputHandle[F[_]](selector: String, backend: HandleBackend[F]) extends ElementHandle[F](selector, backend): + def fill(value: String)(using Trace): F[Unit] = backend.fill(selector, value) + +/** `