Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}
Expand Down Expand Up @@ -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')
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 ~<module>/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`) |
Expand Down
45 changes: 43 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions chekhov/src/main/scala/ascent/chekhov/HandleBackend.scala
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions chekhov/src/main/scala/ascent/chekhov/Handles.scala
Original file line number Diff line number Diff line change
@@ -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)

/** `<input>` — 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)

/** `<textarea>` — same shared verbs as [[InputHandle]]. */
final class TextAreaHandle[F[_]](selector: String, backend: HandleBackend[F])
extends ElementHandle[F](selector, backend):
def fill(value: String)(using Trace): F[Unit] = backend.fill(selector, value)

/** `<button>` — no `fill`. */
final class ButtonHandle[F[_]](selector: String, backend: HandleBackend[F]) extends ElementHandle[F](selector, backend)

/** `<select>` — `selectOption` is JS-only until Chekhov's `Locator` grows it. */
final class SelectHandle[F[_]](selector: String, backend: HandleBackend[F]) extends ElementHandle[F](selector, backend)
31 changes: 31 additions & 0 deletions chekhov/src/main/scala/ascent/chekhov/Selectors.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package ascent.chekhov

import ascent.domtypes.{ElementKey, VoidElementKey}

/** CSS selector fragments built from the HTML lattice. No live DOM, no Playwright. */
object Selectors:

/** Escape a value that will sit inside a double-quoted CSS attribute selector. */
def escapeAttr(value: String): String =
value.replace("\\", "\\\\").replace("\"", "\\\"")

def testIdSelector(testId: String): String =
s"""[data-testid="${escapeAttr(testId)}"]"""

def taggedSelector(tagName: String, testId: String): String =
s"${tagName}${testIdSelector(testId)}"

def taggedTestId(testId: String, tag: ElementKey | VoidElementKey): String =
val name = tag match
case k: ElementKey => k.domName
case k: VoidElementKey => k.domName
taggedSelector(name, testId)

def placeholderSelector(tagName: String, text: String): String =
s"""${tagName}[placeholder="${escapeAttr(text)}"]"""

def roleSelector(role: String): String =
s"""[role="${escapeAttr(role)}"]"""
end Selectors

export Selectors.{escapeAttr, testIdSelector, taggedSelector, taggedTestId, placeholderSelector, roleSelector}
45 changes: 45 additions & 0 deletions chekhov/src/main/scala/ascent/chekhov/TagHandle.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package ascent.chekhov

/** Maps an [[HtmlTag]] marker onto a handle constructor.
*
* `ElementKey` is unparameterized (`val button: ElementKey`), so a given on `Elements.button.type` is not inferred
* from `E.button`. Callers pass [[HtmlTag.button]] (or `root.button("inc")`) so the handle kind is a compile-time
* fact. Named `HtmlTag`, not `Tag`, to stay clear of `zio.Tag`.
*/
trait TagHandle[K]:
type Handle[F[_]] <: ElementHandle[F]
def tagName: String
def wrap[F[_]](selector: String, backend: HandleBackend[F]): Handle[F]

/** Singleton markers that preserve distinct types under inference (`HtmlTag.input` vs `HtmlTag.button`). */
object HtmlTag:
object input
object button
object textarea
object select

object TagHandle:
given input: TagHandle[HtmlTag.input.type] with
type Handle[F[_]] = InputHandle[F]
def tagName: String = "input"
def wrap[F[_]](selector: String, backend: HandleBackend[F]): InputHandle[F] =
InputHandle(selector, backend)

given button: TagHandle[HtmlTag.button.type] with
type Handle[F[_]] = ButtonHandle[F]
def tagName: String = "button"
def wrap[F[_]](selector: String, backend: HandleBackend[F]): ButtonHandle[F] =
ButtonHandle(selector, backend)

given textarea: TagHandle[HtmlTag.textarea.type] with
type Handle[F[_]] = TextAreaHandle[F]
def tagName: String = "textarea"
def wrap[F[_]](selector: String, backend: HandleBackend[F]): TextAreaHandle[F] =
TextAreaHandle(selector, backend)

given select: TagHandle[HtmlTag.select.type] with
type Handle[F[_]] = SelectHandle[F]
def tagName: String = "select"
def wrap[F[_]](selector: String, backend: HandleBackend[F]): SelectHandle[F] =
SelectHandle(selector, backend)
end TagHandle
22 changes: 22 additions & 0 deletions chekhov/src/main/scala/ascent/chekhovExports.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ascent

/** Export facade for `ascent-chekhov`. Contributes the shared handle types to the open `package ascent` so tests can
* `import ascent.*` and see [[InputHandle]] / [[ButtonHandle]]. Platform backends (`withMounted`, `Page` extensions)
* are exported from the js / jvm trees.
*/
export ascent.chekhov.{
HandleBackend,
ElementHandle,
InputHandle,
TextAreaHandle,
ButtonHandle,
SelectHandle,
HtmlTag,
TagHandle,
Selectors,
testIdSelector,
taggedSelector,
taggedTestId,
placeholderSelector,
roleSelector,
}
Loading