From nothing to a green end-to-end scenario. Assumes hive-cljs is already mounted in your hive-mcp coordinator — if it isn't, see hosting.md.
Everything below was executed while building this library; the numbers and error
strings are real. If you would rather start from something that already runs,
example/ is this walkthrough's end state, committed.
my-app/
deps.edn
shadow-cljs.edn
package.json
public/index.html
src/my/app.cljs
deps.edn:
{:paths ["src" "public"]
:deps {org.clojure/clojure {:mvn/version "1.12.1"}
thheller/shadow-cljs {:mvn/version "3.4.11"}
reagent/reagent {:mvn/version "1.2.0"}
re-frame/re-frame {:mvn/version "1.4.3"}
nrepl/nrepl {:mvn/version "1.7.0"}
cider/cider-nrepl {:mvn/version "0.58.0"}}}
shadow-cljs.edn — the two ports hive-cljs needs come from here:
{:deps true
:nrepl {:port 7889} ; ← the runtime channel (cljs eval, :expect-sub)
:dev-http {8280 "public"} ; ← what the browser navigates to
:builds {:app {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn my.app/init}}}}}
npm deps (React 19 needs a process shim under shadow-cljs):
npm init -y && npm install react react-dom process
Omitting process fails the build with
The required JS dependency "process" is not available — which hive-cljs will
report verbatim under :build/errors.
Two places work; pick one (details in configuration.md).
In .hive-project.edn — preferred when the project is already a hive project,
since it keeps one descriptor:
{:project-id "my-app"
:parent "hive"
:hive.cljs {:shadow {:nrepl-port 7889}
:builds {:app {:http-port 8280}}}}
Or in hive-cljs.edn — for a project that is not part of a hive tree:
{:hive.cljs/shadow {:nrepl-port 7889}
:hive.cljs/builds {:app {:http-port 8280}}}
The rest has defaults: shadow at localhost:9630, base-url inferred from
:http-port, chromium, headless, 15s timeout, 500ms watch debounce.
clojure -M -m shadow.cljs.devtools.cli watch app
⚠️ Check the port it actually bound. shadow takes the next free one with only a warning:
TCP Port 9630 in use.
server version: 3.4.11 running at http://localhost:9633
A manifest still pointing at 9630 will connect cleanly to another project's
server and return confident, wrong build verdicts. Set :shadow {:port 9633} to
match, and sanity-check that cljs status reports files you recognise.
code {command: "cljs doctor", directory: "/abs/path/to/my-app"}
{:manifest :ok
:builds [:app]
:base-url "http://localhost:8280"
:ports {:build-tool :ok :cljs-eval :ok :browser :ok}
:runtimes {:status :ok
:pinned nil
:by-build {:app {:connected [{:client-id 66
:user-agent "Firefox 152.0 [Linux x86_64]"
:host :browser}]}}}}
:runtimes names every browser currently attached to each declared build — the
thing that decides what a state assertion actually answers about, and otherwise
invisible. :pinned is nil outside a run; during one it holds the runtime bound
to the driven page. The channel reports {:status :down} when it never
connected and {:status :unsupported} when the adapter cannot enumerate, so an
empty inventory is never confused with an unanswerable question.
More than one runtime on a build raises a :runtime/ambiguous warning. It is
not a failure — scenarios pin their own page — but ad-hoc cljs eval has no page
to pin to, so that is exactly when a manual eval starts disagreeing with a run.
Any :down port carries a typed reason:
| error | meaning |
|---|---|
:relay/server-unreachable | no shadow server on that host/port |
:cljs-eval/no-nrepl-port | add :nrepl-port to the config |
:cljs-eval/connect-failed | nREPL port wrong or shadow not up |
:browser/unavailable | browser adapter missing from the classpath |
{:hive.cljs/e2e
{:scenarios [{:id :login :tags [:smoke]
:steps [[:goto "/"]
[:wait-for "#go"]
[:expect-hidden "#hi"]
[:click "#go"]
[:expect-text "#hi" "Hello, pedro"]
[:expect-sub [:current-user] "#(= % \"pedro\")"]
[:screenshot "logged-in"]]}]}}
code {command: "cljs e2e run", directory: "/abs/path/to/my-app", scenario: "login"}
login: pass (8 pass, 0 fail, 0 error, 0 incomplete, 0 skipped)
:expect-sub is the part a DOM-only tool cannot do: it evaluates
@(re-frame.core/subscribe [:current-user]) inside the running app. See
steps.md for the full vocabulary.
The runtime channel needs a page open, because that is what connects a JS runtime
to shadow. No available JS runtime from cljs eval means no browser has loaded
the app — not a misconfiguration. A scenario avoids this by construction: its
:goto opens the page before any runtime step runs.
Inside a scenario the runtime channel is pinned to that page specifically, so
other tabs you have open cannot answer its assertions. Ad-hoc cljs eval has no
such page to pin to and still goes to whichever runtime shadow picks — worth
remembering when a manual eval and a scenario disagree.
A run reporting :incomplete means assertions could not be attempted at all — no
runtime connected, or the driven page could not be identified. That is neither a
failure of the app nor a pass: nothing was proven. If the shadow nREPL was
restarted or OOM-killed, hive-cljs may be holding a dead connection; cljs close
re-probes it, and cljs doctor should show :cljs-eval :ok before you trust a
green run.
{:hive.cljs/watch {:on-build-success [[:run-e2e {:tags [:smoke]}]]
:debounce-ms 300}}
code {command: "cljs watch start", directory: "/abs/path/to/my-app"}
Now editing a .cljs file recompiles, and the watcher runs the smoke scenarios
unprompted:
{:at …685 :build :app :state :compiling :decisions [:ignore]}
{:at …783 :build :app :state :completed :decisions [:run-e2e]}
report -> :pass
cljs watch status shows the debounce config, per-build last-run stamps and the
recent log. cljs watch stop unsubscribes.
The same execution path, from deftest:
(ns my.app.e2e-test
(:require [clojure.test :refer [deftest is use-fixtures]]
[hive-cljs.test-api :as cljs-e2e]))
(def root "/abs/path/to/my-app")
(use-fixtures :once (fn [f] (f) (cljs-e2e/close! root)))
(deftest login-works
(let [res (cljs-e2e/run-scenario! root :login)]
(is (cljs-e2e/passed? res) (cljs-e2e/explain res))))
(deftest ad-hoc-steps-need-no-manifest-entry
(is (cljs-e2e/passed?
(cljs-e2e/run-steps! root :probe
[[:goto "/"] [:click "#go"]
[:expect-sub [:current-user] "some?"]]))))
explain renders the summary plus the first failing step, so a red CI log tells
you which step broke and what it saw.
Close what you opened, or the JVM will not exit. A session holds a relay
connection; a live connection keeps the process alive after the last deftest
returns, so CI hangs on a suite that was already green. close! releases one
project — a suite that touches more than one wants the whole-suite teardown:
(use-fixtures :once (fn [f] (f) (cljs-e2e/close-all!)))
A shutdown hook is not an alternative here: the JVM runs hooks only once it has decided to exit, and it decides that only when the last non-daemon thread ends. The hook would run exactly when it is no longer needed. hive-cljs's own relay pool is created daemon for that reason, so a forgotten teardown degrades to a leaked connection rather than a wedged CI job — but closing explicitly is still the contract.
A step fails but the app looks right in a screenshot. Check whether the DOM
assertion and the runtime assertion disagree: :expect-text red while
:expect-sub green means state is correct and rendering is not — classically a
form-2 Reagent component dereferencing its subscription in the outer let.
:fill reports pass but the input stays empty. Reproduce against a plain
static HTML page. If it fails there too, the app is exonerated and the driver or
environment is at fault — in one sandboxed shell here, focus landed but
keystrokes never reached the renderer, in raw playwright-java with no hive code
involved.
A manual cljs eval and a scenario disagree about the same state. Read
:runtimes in cljs doctor. A scenario pins the page it drives; an ad-hoc eval
does not, so with two runtimes attached they can legitimately answer about
different pages. Closing the stray tab makes the two agree.
Build verdicts describe a project you don't recognise. You are pointed at
another shadow server; see the port warning in step 3. cljs staleness names it
directly — :staleness/server :mismatch means the builds you declare and the
builds that server actually serves are disjoint sets.
A config edit seems to have no effect. cljs staleness reports
:staleness/manifest; :stale means a contributing file changed since the
session cached it. Any command reopens the session automatically in that case, so
this is a diagnosis rather than a fix — if it reports :fresh while you expect
otherwise, you edited a file that is not in :manifest/sources, and cljs doctor
will tell you which files those are.
Config in a parent directory is ignored. That is the default. Walking up to
find the nearest config is unconditional, but inheriting from an ancestor is
opt-in: set :hive.cljs/inherit true in the child. See
configuration.md.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |