Liking cljdoc? Tell your friends :D

Architecture

CPPB-stratified, three ports plus three optional capabilities. Dependencies point down; effects live only at the boundary.

SURFACE    test-api (fns) · test (defscenarios → clojure.test vars)
BOUNDARY   addon/handlers · watch/supervisor · boundary   ← ports injected as arguments
PIPELINE   plan     (scenario + manifest → run-plan)
           watch    (build event + policy → decisions)
           mutation (fault + run-plan → mutated plan; reports → score)
PROMOTE    verdict   (raw payload → verdict / report)
           step      (authored datum → op, OCP rule chain)
           staleness (cached state + world → is the view still true)
COLLECT    manifest (raw EDN → normalized, defaults resolved)
TYPES      schema (malli value objects) · ports · profile (provider behaviour as data)

DIALECT    dialect/re-frame · dialect/js   (op → source text a runtime evaluates)
             └ resources/hive_cljs/probe.js — the JS half of dialect/js's contract
REGISTRY   toolchain (id → IToolchain)     ← the composition root's swap point
ADAPTERS   shadow/toolchain → IToolchain           (:shadow-cljs)
             ├ shadow/relay → IBuildTool
             └ shadow/nrepl → ICljsEval + IRuntimeDialect + IRuntimeIntrospection
           browser/toolchain → IToolchain          (:browser — every other stack)
             ├ build/process   → IBuildTool        (argv + exit code)
             └ browser/page-eval → ICljsEval + IRuntimeDialect + ISessionBound
           browser/playwright → IBrowserDriver + IPageMarker + IPageEval

A vendor is named only in an adapter namespace. Everything above depends on hive-cljs.ports. That includes system, the composition root — it resolves a project's declared toolchain through the registry rather than calling a connector by name, which is what makes the extension points below reachable at all rather than merely declared.

The three ports

(defprotocol IBuildTool
  (builds [this]) (build-status [this build-id]) (compile-once! [this build-id])
  (subscribe! [this k f]) (unsubscribe! [this k]))

(defprotocol IBrowserDriver
  (open-session! [this opts]) (perform! [this session op]) (close-session! [this session]))

(defprotocol ICljsEval
  (eval-cljs [this build-id form-str]) (runtime-available? [this build-id]))

ICljsEval is what makes this more than a Playwright wrapper — it lets one scenario assert on the DOM and on live re-frame state.

The toolchain seam

The three ports say what a channel does. IToolchain says who opens it:

(defprotocol IToolchain
  (open-build-tool [this manifest]) (open-runtime [this manifest])
  (close-build-tool! [this build-tool]) (close-runtime! [this runtime]))

system/open! resolves :manifest/toolchain:hive.cljs/toolchain in config, defaulting to :shadow-cljs — through hive-cljs.toolchain and asks the result for both channels. So a stack this library has never heard of is mounted with a register!:

(toolchain/register! :my-stack (reify ports/IToolchain …))
;; or a symbol, resolved on first use
(toolchain/register! :my-stack 'my.ns/toolchain)

Teardown lives on IToolchain rather than on the ports because adding a method to a shipped port protocol would break every third-party implementation of it.

Two properties the registry is built to keep:

  • Nothing loads a vendor to know it exists. A shipped adapter is registered as a symbol and requiring-resolved on first use — the same soft resolution browser.factory uses — so the subsystem loads and tests with none of its vendors on the classpath, and a missing one is a typed error at open time.
  • An unresolvable toolchain explains both dead channels. doctor reports it under :ports {:toolchain :down} and repeats the error as the reason for the build and runtime channels, rather than showing two unexplained absences that send the reader hunting for a server that was never the problem.

The browser is deliberately not behind this: IBrowserDriver is already stack agnostic — it drives a page, and a page is a page whatever compiled it.

The runtime dialect

A step says what to assert; the dialect says what that means in the language the runtime evaluates. Two optional capabilities on the runtime channel:

(defprotocol IRuntimeDialect
  (assertion-source [this op]) (probe-source [this op]))

(defprotocol IRuntimeIntrospection
  (invariant-source [this schema frame])
  (registry-source [this kinds]) (neutralize-source [this kind id]))

:expect-sub / :expect-db / :dispatch are re-frame vocabulary, not ClojureScript vocabulary and certainly not shadow-cljs vocabulary — so their rendering lives in hive-cljs.dialect.re-frame, and the shadow nREPL channel implements the capability by delegating there. boundary asks the channel.

Both are optional and both degrade to :incomplete, never to a pass:

  • no IRuntimeDialect — the channel evaluates but speaks no step vocabulary
  • a kind this dialect does not render — assertion-source returns nil rather than assembling an expression out of the wrong arguments
  • no IRuntimeIntrospection — an :app-db-schema invariant reports that it was never asserted, and cljs e2e mutate --auto says to declare :faults instead

They are two protocols rather than one because rendering an assertion and rewriting a live handler registry are different powers: a channel may well do the first for any application and the second for none.

Why the probe is injected, not published

dialect/js owns both halves of the :expect-state contract: the expressions that read it, and resources/hive_cljs/probe.js, which answers them. ISessionBound/bootstrap-source hands that to the boundary, IPageBootstrap installs it as a document init script before the first op — before, because a contract the application calls into at startup is useless installed after startup.

It was briefly an npm package. Measuring it settled the question: ~90% of it is the READ side, which is this library's contract and not the application's code. The application's whole share is one guarded line — window.__hive__?.expose('model', () => …) — which does not justify a dependency, and injecting instead means there is no version skew between shim and runner, the classic failure mode of a test-shim package. The probe also never reaches production, so no build flag guards it.

dialect_test.clj carries a guard that boundary requires no namespace under hive-cljs.shadow or hive-cljs.browser — the require that used to break that looked entirely reasonable at the call site.

The optional capabilities

Segregated rather than folded into the ports above: an adapter that implements none still works, and third-party implementations of the three ports keep compiling.

(defprotocol IPageMarker        ; browser side
  (mark-session! [this session token]))

(defprotocol IRuntimeAffinity   ; runtime side — mutates
  (bind-runtime! [this build-id token]) (unbind-runtime! [this]))

(defprotocol IRuntimeInventory  ; runtime side — observes
  (connected-runtimes [this build-id]) (pinned-runtime [this]))

IRuntimeInventory is what doctor reports under :runtimes. It is kept apart from IRuntimeAffinity because observing is not binding: an adapter may be able to say what is attached without being able to pin anything, and adding a method to the shipped affinity protocol would break every implementation of it.

The first two answer which page am I asserting about. The driver stamps every document its session loads with a token; the eval channel finds the connected runtime carrying that token and pins evaluation to it. Without this the CLJS REPL answers from whatever runtime the toolchain happens to pick, so any other open tab silently decides every state assertion — a whole class of tests that grade the wrong page while looking green.

Two constraints shape the wiring in boundary/run-plan!:

  • The stamp must survive navigation. A scenario opens with :goto, which discards anything set on the previous document — so the mark is installed as an init script on the browser context, not evaluated once.
  • Binding cannot happen at session open. The page is blank then and no runtime is attached to the build yet; the app registers only once :goto loads it. So the bind is lazy — once per run, immediately before the first runtime step — and released in a finally.

boundary/affinity-possible? gates the whole thing on satisfies?, so this is a capability, not a requirement.

Layer notes

schema is the single source: it drives m/=> contracts on the pure fns AND the property/mutation facets synthesized by hive-schemas.test. Tighten a schema and the tests tighten with it.

profile holds provider behaviour as data — relay op spelling, sync-db table and attribute names, status vocabulary, browser launch defaults — in a registry that is the DIP swap point. Swapping a toolchain is a register!, not a code hunt. Values were read off shadow-cljs's own source, not third-party clients: every op lives in the shadow.cljs namespace, and build status arrives via the sync-db rather than a subscribe topic.

step is an ordered IStepRule chain — first match wins, so a new step kind is an appended rule and an earlier rule can shadow a built-in. See steps.md.

plan is pure orchestration: it resolves the base URL, compiles steps to ops and produces a RunPlan as data. No port is touched.

boundary is the only place a plan meets a port, and every collaborator arrives as an argument ({:build-tool … :driver … :cljs-eval …}).

watch decides; watch/supervisor executes. Debounce is decided purely from timestamps; only sleeping, subscribing and running live in the supervisor.

Testing

Every test injects a stub through the ports — StubBuildTool (with emit-build! to simulate a compile finishing), StubDriver (recording, with a fail-on variant), StubCljsEval. No test namespace names a vendor, so the suite runs with nothing installed:

clojure -M:test          # 311 tests, 1137 assertions
node test/js/probe_test.mjs   # 31 — the injected probe, which Clojure cannot exercise

defscenarios is tested by expanding it against real temp project trees and inspecting the emitted forms — no browser, and the generated bodies are never invoked. The one exception is the empty-selection case, whose generated test is evaluated and run, because "a selection that matches nothing fails" is a claim about what the var does, not about its shape.

The stubs also model the absence of the optional capabilities — driver-without-marking and cljs-eval-without-affinity — so the degradation path is covered rather than assumed.

A stub can only discharge a contract it actually mirrors. The runtime-affinity work was additionally verified against real ports by an A/B on a live app with a decoy browser open: pinned passed, and the same run with IPageMarker reified away failed on the decoy's state. The inventory report was verified the same way — one runtime and no warning, then a second browser opened on the same build and the :runtime/ambiguous warning appeared with both user-agents named. A stub holds one runtime by construction and cannot express either hazard; where that is true, the real-port check is the evidence.

Pure layers additionally carry schema-synthesized property + mutation facets via hive-schemas.test/deftrifecta-from-schema — no hand-written generators.

One caveat worth repeating: deftrifecta-predicate fits only a predicate that IS its schema. A state check like build-ok? ignores most keys, so schema corruption cannot flip it; use deftrifecta-from-schema with :out :boolean and a :rel restating the decision.

Extension points

Want toDo
add a step kindappend an IStepRule (+ a perform-op defmethod for browser kinds, or a dialect rendering for runtime kinds)
teach a runtime channel a vocabularyimplement IRuntimeDialect on it, rendering through a hive-cljs.dialect.* namespace
support another frontend stackimplement IToolchain, toolchain/register! it, declare :hive.cljs/toolchain
support another build toolimplement IBuildTool, return it from a toolchain's open-build-tool
swap the browserimplement IBrowserDriver (+ IPageMarker to keep runtime pinning)
swap the runtime channelimplement ICljsEval and return it from a toolchain's open-runtime (+ IRuntimeAffinity to keep runtime pinning, IRuntimeInventory to keep doctor's runtime report)
change what a build event triggersadd a :hive.cljs/watch action and a watch/action->decision case

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close