We want to be able to run/test Fulcro applications in CLJ. There is already extensive support in Fulcro for running in CLJ, but the async operation causes problem, and we also need to make sure that the rendering system has the properly-bound dynamic variables.
we can run from CLJ (with the html hiccip deps), and that is fun for demo purposes, but for testing it has a few limitations:
- There's no clean way to write tests where the full-stack async behavior is controlled.
- Synchronous control is needed. For example: have a short-term history of render frames, where CLJ can explicitly control that a render happens after optimistic action, remote happens (possibly async, with blocking in tests), then another render. Probably just a plug-in for
submit-tx!(transaction processing). - Pathom could be sync or async on the server.
- Synchronous control is needed. For example: have a short-term history of render frames, where CLJ can explicitly control that a render happens after optimistic action, remote happens (possibly async, with blocking in tests), then another render. Probably just a plug-in for
- Remotes in CLJ are not remote, but there needs to be a clearer interface that leverages the proper ring stack of the API so that things like authentication, env creation, etc can be managed in tests.
Here's an example application that can run from CLJ (with the html hiccip deps) and has these limitations:
First, the application support:
(ns app.application
(:require
#?(:cljs [com.fulcrologic.fulcro.dom :as dom]
:clj [com.fulcrologic.fulcro.dom-server :as dom])
[clojure.string :as str]
[clojure.pprint :refer [pprint]]
[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.components :as comp]
[com.fulcrologic.fulcro.algorithms.denormalize :as fdn]
[com.rpl.specter :as sp]
[taoensso.timbre :as log]
[taipei-404.html :refer [html->hiccup]]))
(defn hiccup-element-by-id [hiccup id]
(sp/select-first (sp/walker (fn [n]
(and
(vector? n)
(map? (second n))
(= id (:id (second n))))))
hiccup))
(defn element->hiccup [element]
#?(:clj
(sp/transform
(sp/walker map?)
(fn [m] (dissoc m :data-reactroot :data-reactid :data-react-checksum))
(first (html->hiccup (dom/render-to-str element))))))
(defn render-element [element]
(pprint (element->hiccup element)))
(defn render-app-element [{::app/keys [state-atom runtime-atom]} id]
(let [state-map @state-atom
{::app/keys [root-class root-factory]} @runtime-atom
query (comp/get-query root-class state-map)
tree (fdn/db->tree query state-map state-map)]
(-> (root-factory tree)
(element->hiccup)
(hiccup-element-by-id id))))
(defn click-on! [app id]
#?(:clj
(let [[_ {:keys [onClick]}] (render-app-element app id)
onClick (str/replace onClick """ "\"")
txn (when (string? onClick) (some-> onClick read-string))]
(when txn
(comp/transact! app txn)))))
(defn txn-handler
"A CLJC function for running transactions as handlers. In CLJ emits the transaction as a string. In CLJS it
emits a fn."
[app-ish txn]
#?(:clj (pr-str txn)
:cljs (fn [] (comp/transact! app-ish txn))))
(defn build-app
([] (build-app (volatile! true)))
([render-data-atom?]
(let [last-state (volatile! {})]
#?(:cljs
(app/fulcro-app {})
:clj
(letfn [(render [{::app/keys [runtime-atom state-atom] :as app} {:keys [force-root?]}]
(let [state-map @state-atom]
(when (or force-root? (not= state-map @last-state))
(let [{::app/keys [root-class root-factory]} @runtime-atom
query (comp/get-query root-class state-map)
tree (fdn/db->tree query state-map state-map)]
(vreset! last-state state-map)
(if @render-data-atom?
(pprint tree)
(binding [comp/*app* app
comp/*parent* nil
comp/*shared* (comp/shared app)]
(render-element (root-factory tree))))))))]
(app/fulcro-app
{:optimized-render! render}))))))and a TODO app that uses it:
(ns app.todo
(:require
#?(:cljs [com.fulcrologic.fulcro.dom :as dom]
:clj [com.fulcrologic.fulcro.dom-server :as dom])
[app.apis.todo :as todo]
[app.sample-servers.registry]
[com.fulcrologic.fulcro.data-fetch :as df]
[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.fulcro.algorithms.merge :as merge]
[com.fulcrologic.fulcro.algorithms.data-targeting :as target]
[com.fulcrologic.rad.type-support.date-time :as dt]
[app.application :refer [build-app render-app-element click-on! txn-handler]]))
(defonce render-data? (atom false))
(defonce SPA (build-app render-data?))
(defsc Item [this {:item/keys [id label complete?]}]
{:query [:item/id :item/label :item/complete?]
:ident :item/id}
(dom/div {:id (str "item" id)}
(dom/input {:type "checkbox"
:id (str "item" id "-checkbox")
:onClick (txn-handler this [(todo/set-complete {:item/id id :item/complete? (not complete?)})])
:checked (boolean complete?)})
(dom/span (str label))))
(def ui-item (comp/factory Item {:keyfn :item/id}))
(defsc TodoList [this {:list/keys [id title items]}]
{:query [:list/id :list/title {:list/items (comp/get-query Item)}]
:ident :list/id}
(dom/div {:id (str "list" id)}
(dom/h4 {} (str title))
(dom/ul {}
(mapv ui-item items))))
(def ui-list (comp/factory TodoList {:keyfn :list/id}))
(defsc Root [this {:todo/keys [lists]}]
{:query [{:todo/lists (comp/get-query TodoList)}]
:initial-state {:todo/lists []}}
(dom/div
(mapv ui-list lists)))
(defn numbered-name
"Generates a string based on nm that has a number appended which
will likely be different each time you call it."
[nm]
(str nm "-" (mod (long (/ (dt/now-ms) 100)) 10000)))Use fulcro-in-clj.md (this file) to build up the plan and analysis that covers what work we need to do, and let's add fully-tested support to the CLJ-side of this library, including full tests with an application that uses dynamic routing (which in turn uses UI state machines) and forms. We MUST NOT modify anything that would change how the CLJS implementation works at all. Fulcro is highly-pluggable, and we should not need to modify any existing implementation namespace. If some kind of tweak is needed then make sure reader conditionals isolate that change to CLJ only.
I'm thinking the transaction processing (see tx_processing.cljc) can be dramatically simplified for CLJ mode. A CLJ remote accepts a function that can take EDN and either synchronously returns the result, or returns an async channel where the result will appear. Thus the remote implementation can control blocking (on the channel) using <!! to block. If integrated with the transaction processing properly (a customized version, perhaps) then we should be able to run any number of Fulcro transactions (via transact! or load!), render a frame before we do the "I/O", simulate the network round trip, then render a frame. Since this can then be controlled in a synchronous way we can use this for both development experimentation (look at the before/after render, which could be stored in a length-limited queue in the runtime atom of the app), and for syncrhonous testing cases. Of course all of this should be in new namespaces for now.
The existing transaction processing system (tx_processing.cljc) already has significant CLJ support:
Default Transaction Processing (default-tx!):
- Uses
scheduling.cljcfor async deferral - CLJ mode uses
core.asyncchannels for scheduling (async/timeout,async/go) - Queues are stored in the runtime-atom:
::submission-queue,::active-queue,::send-queues
Synchronous Transaction Processing (synchronous_tx_processing.cljc):
- Already exists! Uses atoms instead of async scheduling
with-synchronous-transactionsinstalls sync tx processor on an apprun-queue!processes all work in a loop until complete- CLJ-specific thread tracking via
in-transactionmacro - Blocks calling thread with
Thread/sleeppolling until complete
Key Functions:
submit-sync-tx!- Synchronous transaction submissionrun-all-immediate-work!- Process one step of all queuesavailable-work?- Check if more work exists
Remotes are simple maps with :transmit! function:
{:transmit! (fn [remote send-node] ...)
:abort! (fn [remote abort-id] ...)}The send-node contains:
::txn/ast- EQL AST to send::txn/result-handler- Function to call with{:status-code :body ...}::txn/update-handler- Progress callback
Critical Insight: The remote is responsible for calling result-handler. It can do so synchronously OR asynchronously. For CLJ testing, we create a synchronous remote.
Algorithm Override Points:
:optimized-render!- Main render function, receives(app options):core-render!- Coordinator that calls optimized-render!:before-render- Hook called before rendering
Dynamic Vars (must be bound for component code):
comp/*app*- Current Fulcro appcomp/*parent*- Parent component (nil for root)comp/*shared*- Shared props
Existing CLJ Rendering (dom-server):
render-to-strproduces HTML string- Works with the example code's hiccup conversion
Core Functions:
begin!- Start a state machine (wraps as mutation)trigger!- Send events to state machine (wraps as mutation)- Handlers receive
envand return modifiedenv
Async Considerations:
- Timeouts use
sched/deferwhich usescore.asyncon CLJ - Timeout events queue through
trigger-state-machine-event!mutation
Testing Strategy:
- Sync tx processing handles async naturally via thread blocking
- Can mock
sched/deferfor deterministic timeout testing - Can manually trigger timeout events
Core Components:
RouterStateMachine- Manages route transitions- States:
:initial,:deferred,:pending,:failed,:routed will-enterreturns:route-immediate,route-deferred, orroute-with-path-ordered-transaction
Route Change Flow:
change-route!validates and starts routing- For each router, calls
will-enteron target - If immediate: applies route directly
- If deferred: waits for
target-ready!mutation
Timeout Handling:
:delay-timerfires after ~20ms to show pending UI:error-timerfires after ~5000ms for failed state- For testing: can mock timers or manually trigger events
All form state operations are pure functions on EDN:
add-form-config*- Initialize form trackingdirty-fields- Get changed fieldsmark-complete*- Mark fields as validatedentity->pristine*- Commit changespristine->entity*- Revert changes
Works identically in CLJ and CLJS.
- No CLJS Changes: All new code in new namespaces or behind
#?(:clj ...)conditionals - Synchronous Control: Tests can step through transaction phases explicitly
- Render Frame Capture: Store render history for assertions
- Ring Integration: Remotes can use actual ring handler stack
- Pathom Support: Both sync and async Pathom with blocking
┌─────────────────────────────────────────────────────────────────┐
│ CLJ Testing Framework │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Test App │ │ Render History │ │
│ │ Builder │ │ (in runtime) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────────▼──────────────────────▼────────┐ │
│ │ CLJ Test Application │ │
│ │ - Sync TX Processing │ │
│ │ - Frame-capturing render │ │
│ │ - Controllable execution │ │
│ └────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────▼────────┐ ┌─────────────────┐ │
│ │ CLJ Remote │ │ Timer Control │ │
│ │ (sync/async) │ │ (mock/advance) │ │
│ └────────┬────────┘ └─────────────────┘ │
│ │ │
│ ┌────────▼────────────────────────────────────────────┐ │
│ │ Backend Integration Layer │ │
│ │ - Ring handler invocation │ │
│ │ - Pathom parser (sync or async via <!! blocking) │ │
│ │ - Mock response injection │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Main entry point for CLJ testing support.
(ns com.fulcrologic.fulcro.clj-testing
"CLJ-only testing support for Fulcro applications."
(:require
[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.raw.application :as rapp]
[com.fulcrologic.fulcro.algorithms.tx-processing.synchronous-tx-processing :as stx]
[com.fulcrologic.fulcro.components :as comp]
[com.fulcrologic.fulcro.algorithms.denormalize :as fdn]
[clojure.core.async :as async]))Key Functions:
(defn build-test-app
"Create a test application configured for synchronous testing.
Options:
- :root-class - Root component class (optional, can mount later)
- :remote - Remote handler function or map
- :initial-state - Initial state map
- :render-history-size - Number of frames to keep (default 10)
- :shared - Static shared props"
[{:keys [root-class remote initial-state render-history-size shared]
:or {render-history-size 10}}]
...)(defn render-frame!
"Force a render and capture the frame in history.
Returns the rendered tree (denormalized props)."
[app]
...)(defn render-history
"Get the render history (newest first).
Each entry: {:state state-map :tree denormalized-tree :timestamp ms}"
[app]
...)(defn last-frame
"Get the most recent render frame."
[app]
...)(defmacro with-controlled-execution
"Execute body with fine-grained control over transaction phases.
Yields control object with:
- :process-optimistic! - Run optimistic actions only
- :process-remotes! - Execute remote calls (blocking)
- :render! - Force a render frame
- :wait-idle! - Block until all work complete"
[app & body]
...)CLJ remote implementations.
(ns com.fulcrologic.fulcro.clj-testing.remote
"Remote implementations for CLJ testing.")(defn sync-remote
"Create a synchronous remote that calls handler-fn directly.
handler-fn: (fn [eql-request] response-body) or async channel
Options:
- :simulate-latency-ms - Add artificial delay
- :transform-request - (fn [request] transformed-request)
- :transform-response - (fn [response] transformed-response)"
[handler-fn & {:keys [simulate-latency-ms transform-request transform-response]}]
{:transmit! (fn [this {:keys [::txn/ast ::txn/result-handler] :as send-node}]
(let [eql (eql/ast->query ast)
result (if (satisfies? async/ReadPort handler-fn)
(async/<!! (handler-fn eql))
(handler-fn eql))]
(result-handler {:status-code 200
:body result})))
:abort! (fn [_ _] nil)})(defn ring-remote
"Create a remote that invokes a Ring handler.
Simulates full HTTP round-trip through Ring middleware stack.
Options:
- :uri - API endpoint URI (default \"/api\")
- :method - HTTP method (default :post)
- :content-type - Request content type (default \"application/transit+json\")
- :headers - Additional headers
- :session - Session data to include"
[ring-handler & {:keys [uri method content-type headers session]
:or {uri "/api" method :post content-type "application/transit+json"}}]
...)(defn pathom-remote
"Create a remote backed by a Pathom parser.
parser: Pathom parser function (fn [env tx] result)
Options:
- :env-fn - (fn [] env) to create parser environment per request
- :async? - If true, parser returns channel; blocks via <!!"
[parser & {:keys [env-fn async?]}]
...)Timer control for testing timeouts.
(ns com.fulcrologic.fulcro.clj-testing.timers
"Timer control for deterministic timeout testing.")(defmacro with-mock-timers
"Execute body with mocked timers.
Yields timer-control with:
- :advance! - (fn [ms]) advance time by ms
- :pending - (fn []) get pending timers
- :fire-all! - (fn []) fire all pending timers"
[& body]
...)Store render history in runtime-atom:
::render-frames ; Vector of {:state :tree :timestamp}
::render-frame-max ; Max frames to keep (ring buffer behavior)Custom optimized-render! that:
- Denormalizes state via
fdn/db->tree - Stores frame in history
- Optionally renders to string (for inspection)
New File: src/main/com/fulcrologic/fulcro/clj_testing.clj
-
Test App Builder
- Wrap
with-synchronous-transactions - Install frame-capturing render
- Configure remotes
- Wrap
-
Render Frame Capture
- Custom
optimized-render!that stores frames - Frame history management (ring buffer)
- State snapshot per frame
- Custom
-
Controlled Execution
- Break transaction processing into phases
- Expose phase-specific execution functions
- Blocking wait for completion
New File: src/main/com/fulcrologic/fulcro/clj_testing/remote.clj
-
Synchronous Remote
- Direct function call
- Channel blocking via
<!! - Error simulation
-
Ring Remote
- Full HTTP simulation
- Transit encoding/decoding
- Session/auth support
-
Pathom Remote
- Parser invocation
- Environment injection
- Async support with blocking
New File: src/main/com/fulcrologic/fulcro/clj_testing/timers.clj
-
Mock Timer System
- Intercept
sched/defer - Queue timer events
- Manual time advancement
- Intercept
-
Integration with UISM
- Timeout event firing
- Routing timer control
New Files in src/test/com/fulcrologic/fulcro/clj_testing/:
- test_app.clj - Components with routing, forms, state machines
- test_mutations.clj - Mutations for testing
- clj_testing_spec.clj - Comprehensive tests
Test Application Features:
- Multi-level dynamic routing (nested routers)
- Deferred routes with loading states
- Forms with validation
- UI state machines (login flow, wizards)
- Remote data loading
Root
├── Header (static)
├── MainRouter (dynamic router)
│ ├── Dashboard (route target)
│ │ └── DashboardStats (loads data)
│ ├── UserSection (route target + nested router)
│ │ ├── UserList (route target, deferred load)
│ │ ├── UserDetail (route target, deferred load)
│ │ └── UserForm (route target, form with validation)
│ └── Settings (route target)
│ └── SettingsForm (form)
└── LoginDialog (UISM-controlled modal)
LoginStateMachine:
- States:
:idle,:entering-credentials,:authenticating,:authenticated,:error - Events:
:show,:submit,:success,:failure,:logout - Tests: Full auth flow, error handling, timeouts
FormWizardStateMachine:
- States:
:step1,:step2,:step3,:submitting,:complete - Events:
:next,:back,:submit,:success,:failure - Tests: Step navigation, validation per step, submission
UserForm:
- Fields:
:user/name,:user/email,:user/role - Validation: Required fields, email format
- Tests: Dirty detection, validation, submit/reset
SettingsForm:
- Fields:
:settings/theme,:settings/notifications - Nested subform: NotificationPreferences
- Tests: Nested form handling, partial saves
/ -> Dashboard
/users -> UserList (deferred)
/users/:user-id -> UserDetail (deferred)
/users/:user-id/edit -> UserForm
/settings -> Settings
/settings/edit -> SettingsForm
-
Basic Transaction
- Submit mutation, verify state change
- Check render frame captured change
-
Load with Deferred Route
- Navigate to UserList
- Verify pending state
- Simulate load complete
- Verify routed state
-
Form Submission
- Edit form fields
- Verify dirty detection
- Submit (optimistic)
- Verify remote called
- Verify pristine after success
-
State Machine Flow
- Start login SM
- Submit credentials
- Mock auth response
- Verify authenticated state
- Test timeout behavior
-
Nested Routing
- Navigate through nested routes
- Verify all routers update
- Test back navigation
-
Error Handling
- Remote failure
- Validation errors
- Timeout errors
(require '[com.fulcrologic.fulcro.clj-testing :as ct])
(require '[com.fulcrologic.fulcro.clj-testing.remote :as ctr])
;; Simple test app with mock remote
(def app (ct/build-test-app
{:root-class Root
:remotes {:remote (ctr/sync-remote mock-handler)}}))
;; With Pathom backend
(def app (ct/build-test-app
{:root-class Root
:remotes {:remote (ctr/pathom-remote my-parser :env-fn make-env)}}))
;; With simple Ring handler (EDN encoding)
(def app (ct/build-test-app
{:root-class Root
:remotes {:remote (ctr/ring-remote my-ring-handler :uri "/api")}}))
;; With full Fulcro Ring middleware (Transit encoding, like CLJS http-remote)
(def app (ct/build-test-app
{:root-class Root
:remotes {:remote (ctr/fulcro-ring-remote my-ring-handler
:uri "/api"
:session {:user/id 1})}}));; Simple (fully synchronous)
(comp/transact! app [(my-mutation {:x 1})])
;; State is already updated, remote already called
;; Controlled execution
(ct/with-controlled-execution app
(fn [{:keys [process-optimistic! process-remotes! render!]}]
;; Submit transaction (queued)
(comp/transact! app [(my-mutation {:x 1})] {:synchronous? false})
;; Process only optimistic actions
(process-optimistic!)
(is (= 1 (:x (ct/current-state app))))
;; Capture pre-remote frame
(render!)
;; Process remotes (blocking)
(process-remotes!)
;; Capture post-remote frame
(render!)
;; Compare frames
(let [[pre post] (ct/render-history app)]
(is (not= (:tree pre) (:tree post))))));; Force render and get tree
(let [tree (ct/render-frame! app)]
(is (= "Expected Title" (get-in tree [:page :title]))))
;; Get history
(let [frames (ct/render-history app)
latest (first frames)]
(is (= 3 (count frames)))
(is (some? (:timestamp latest))))
;; Get specific element (via query)
(let [user (ct/query-tree app [:user/id 1])]
(is (= "John" (:user/name user))))(require '[com.fulcrologic.fulcro.ui-state-machines :as uism])
;; Start state machine
(uism/begin! app LoginMachine ::login {:actor/form LoginForm} {})
;; Verify initial state
(is (= :idle (uism/get-active-state app ::login)))
;; Trigger event
(uism/trigger! app ::login :show {})
(is (= :entering-credentials (uism/get-active-state app ::login)))
;; With mock timers for timeout testing
(require '[com.fulcrologic.fulcro.clj-testing.timers :as timers])
(timers/with-mock-timers
(uism/trigger! app ::login :submit {:username "test" :password "wrong"})
;; Check that timer was scheduled
(is (= 1 (timers/pending-timer-count)))
;; Advance time to trigger timeout
(timers/advance-time! 5001)
(is (= :error (uism/get-active-state app ::login))))(require '[com.fulcrologic.fulcro.routing.dynamic-routing :as dr])
;; Change route
(let [result (dr/change-route! app ["users"])]
(is (= :routing result)))
;; For deferred routes - complete the loading
(dr/target-ready! app [:user-list/id :singleton])
;; Verify route
(is (= [:user-list/id :singleton]
(dr/current-route app Root)))(require '[com.fulcrologic.fulcro.algorithms.form-state :as fs])
;; Initialize form
(swap! (::app/state-atom app)
fs/add-form-config* UserForm [:user/id 1])
;; Modify field
(swap! (::app/state-atom app)
assoc-in [:user/id 1 :user/name] "New Name")
;; Check dirty
(let [form-props (ct/get-props app UserForm [:user/id 1])]
(is (fs/dirty? form-props :user/name))
(is (not (fs/dirty? form-props :user/email))))
;; Submit form (mutation)
(comp/transact! app [(submit-user-form {:user/id 1})])
;; Verify clean after submit
(let [form-props (ct/get-props app UserForm [:user/id 1])]
(is (not (fs/dirty? form-props))))The key insight is that synchronous_tx_processing.cljc already provides most of what we need:
- Synchronous transaction execution
- Thread-safe queue management
- Blocking wait for completion
Our additions layer on top:
- Render frame capture
- Phase-separated execution control
- Testing-friendly remote implementations
- Timer mocking
All functionality achieved via:
- Algorithm overrides (
:optimized-render!,:tx!, etc.) - Remote implementations (map with
:transmit!) - New namespaces (CLJ-only)
The only potential change: If we need reader conditionals for optimization, they go in new branches that don't affect CLJS execution.
- Use atoms for shared state
- Synchronous TX processing already handles thread coordination
- Timer mocking uses thread-local bindings
For testing:
- Render history is bounded (ring buffer)
- Remote calls are synchronous (no scheduling overhead)
- State snapshots are references (not deep copies unless needed)
src/main/com/fulcrologic/fulcro/
├── clj_testing.clj ; Main testing entry point
├── clj_testing/
│ ├── remote.clj ; Remote implementations
│ ├── timers.clj ; Timer control
│ └── internal.clj ; Internal utilities
src/test/com/fulcrologic/fulcro/
├── clj_testing_spec.clj ; Tests for testing framework
├── clj_testing/
│ ├── test_app.clj ; Test application components
│ ├── test_mutations.clj ; Test mutations
│ ├── test_state_machines.clj ; Test state machine definitions
│ └── integration_spec.clj ; Integration tests with full app
No new dependencies required. Uses:
clojure.core.async(already in deps)com.fulcrologic.fulcro.*(internal)
Optional (for ring testing):
- Ring (test dependency)
- Transit (already in deps)
- All tests run synchronously - No flaky async timing issues
- Render frame inspection - Can assert on before/after render state
- Full routing support - Deferred routes work with controlled timing
- Form validation - All form state operations testable
- State machine testing - Can drive SMs through states, test timeouts
- No CLJS impact - All existing CLJS functionality unchanged
- Ring integration - Can test with actual Ring handlers
- Pathom integration - Can test with actual Pathom parsers
-
✅ Core Testing Infrastructure (
clj_testing.clj)build-test-app- Creates test apps with sync transaction processingrender-frame!- Forces render and captures framelast-frame/render-history- Access render historycurrent-state/get-props- State inspection utilitiesreset-app!- Reset to initial statewait-for-idle!/has-pending-work?- Work synchronization
-
✅ Remote Implementations (
clj_testing/remote.clj)sync-remote- Direct function call remotepathom-remote- Pathom parser integrationring-remote- Simple Ring handler remotefulcro-ring-remote- Full middleware pipeline (see below)mock-remote- Canned response testingrecording-remote- Request recording wrapperfailing-remote- Error simulationdelayed-remote- Latency simulation
-
✅ Timer Control (
clj_testing/timers.clj)with-mock-timers- Mock timer contextadvance-time!/set-time!- Time manipulationfire-all-timers!/clear-timers!- Timer controlpending-timers/pending-timer-count- Timer inspection
-
✅ Comprehensive Tests
- 48 tests, 214 assertions
- Core testing framework tests
- Remote implementation tests
- Timer mocking tests
- Dynamic routing tests with nested routers
- State machine tests with timeouts
- Render output verification tests
- Ring session demo with full authentication flow
The fulcro-ring-remote provides the same middleware pipeline as the CLJS
fulcro-http-remote, but calls a Ring handler directly:
(require '[com.fulcrologic.fulcro.clj-testing.remote :as ctr])
;; Basic usage
(def remote (ctr/fulcro-ring-remote my-ring-handler))
;; With custom middleware chains (like CLJS http-remote)
(defn wrap-auth-header [handler token]
(fn [request]
(handler (update request :headers assoc "Authorization" (str "Bearer " token)))))
(def remote
(ctr/fulcro-ring-remote my-ring-handler
:uri "/api"
:request-middleware (-> (ctr/wrap-fulcro-request)
(wrap-auth-header "my-token"))
:response-middleware (-> (ctr/wrap-fulcro-response)
(my-custom-error-handler))))
;; With session/cookies simulation
(def remote
(ctr/fulcro-ring-remote my-ring-handler
:session {:user/id 1 :user/role :admin}
:cookies {"session-id" {:value "abc123"}}))
;; State is accessible for test verification
(let [state @(:state remote)]
(is (= 1 (:user/id (:session state)))))Available Middleware:
wrap-fulcro-request- CLJ port of CLJS middleware, encodes body as transit+jsonwrap-fulcro-response- CLJ port of CLJS middleware, decodes transit+json response
The fulcro-ring-remote fully supports Ring's session middleware (wrap-session).
Session cookies are automatically:
- Parsed from Set-Cookie headers in responses
- Stored in the remote's state for subsequent requests
- Sent as Cookie headers in subsequent requests
This enables testing of authentication flows, session-based state, and other session-dependent features with full Ring middleware integration.
Example: Testing Login/Logout Flow with Ring Sessions
(require '[ring.middleware.session :refer [wrap-session]]
'[ring.middleware.session.memory :refer [memory-store]])
;; Create Ring app with session middleware
(def ring-app
(-> my-api-handler
(wrap-session {:store (memory-store)
:cookie-name "ring-session"})))
;; Create remote - sessions persist automatically via cookies
(def remote (ctr/fulcro-ring-remote ring-app :uri "/api"))
(def app (ct/build-test-app {:root-class Root :remotes {:remote remote}}))
;; Login - server sets session, cookie is captured automatically
(comp/transact! app [(login {:username "admin" :password "secret"})])
;; Subsequent requests include the session cookie
(comp/transact! app [(get-current-user)])
;; => Session is preserved, user is retrieved
;; Check that cookies are being tracked
(let [state @(:state remote)]
(println "Session cookie:" (:cookies state)))Session Isolation Between Tests
Each fulcro-ring-remote instance maintains its own cookie state, so different
test apps have isolated sessions:
;; Two different sessions (isolated)
(def admin-remote (ctr/fulcro-ring-remote ring-app :uri "/api"))
(def guest-remote (ctr/fulcro-ring-remote ring-app :uri "/api"))
(def admin-app (ct/build-test-app {:remotes {:remote admin-remote}}))
(def guest-app (ct/build-test-app {:remotes {:remote guest-remote}}))
;; Admin logs in - only affects admin-app's session
(comp/transact! admin-app [(login {:username "admin" :password "secret"})])
;; Guest is still not logged in (different session)
(comp/transact! guest-app [(protected-action)])
;; => Fails with "Unauthorized"See com.fulcrologic.fulcro.clj-testing.ring-session-demo for a complete
working example with tests.
- Fixed
reset!used onvolatile!inscheduling.cljc:29(changed tovreset!) - Fixed terminal states in state machines needing
::uism/events {}for spec compliance
The current frame capture in clj_testing.clj only captures the denormalized data tree,
not the actual rendered DOM structure. This means:
- Can't inspect what elements would be rendered
- Can't find elements by ID to test click handlers
- Can't verify that onClick handlers are wired correctly
- Can't simulate user interactions like clicking buttons
We need a dom-hiccup namespace that:
- Renders Fulcro components to hiccup data structures
- Preserves real lambdas/functions on elements (not stringify them like
dom-server) - Enables helpers like
find-element-by-idandclick-on!
┌─────────────────────────────────────────────────────────────────┐
│ dom-hiccup.clj │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Tag Functions │ │ Render Engine │ │
│ │ (div, span..) │───▶│ (to-hiccup) │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Hiccup Output │ │
│ │ [:div {:id .. │ │
│ │ :onClick fn} │ │
│ │ children...] │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Hiccup Helpers (in clj_testing.clj) │
├─────────────────────────────────────────────────────────────────┤
│ find-element-by-id - Find element in hiccup tree by :id │
│ find-elements-by-* - Find elements by class, tag, etc. │
│ click-on! - Find element by ID and invoke onClick │
│ element-text - Extract text content from element │
│ element-attr - Get attribute from element │
└─────────────────────────────────────────────────────────────────┘
(ns com.fulcrologic.fulcro.dom-hiccup
"Server-side DOM rendering to hiccup data structures.
Unlike dom-server which renders to HTML strings, this namespace
produces hiccup vectors with real function values preserved.
This enables testing of event handlers and DOM inspection.")
;; Each tag function returns hiccup
(defn div [& args]
;; Returns: [:div {:id "x" :onClick #function} child1 child2 ...]
)
;; Component rendering
(defn render-to-hiccup [element]
"Render a Fulcro component/element to hiccup.
Returns nested vectors with preserved function values.");; Find element by ID
(find-element-by-id hiccup "my-button")
;; => [:button {:id "my-button" :onClick #fn} "Click me"]
;; Find all elements matching predicate
(find-elements hiccup (fn [[tag attrs]] (= tag :button)))
;; => [[:button {...}] [:button {...}]]
;; Click on an element (invoke its onClick)
(click-on! app "my-button")
;; Finds element, extracts onClick, invokes it
;; Get text content
(element-text [:div {} "Hello " [:span {} "World"]])
;; => "Hello World"
;; Get attribute
(element-attr [:input {:type "text" :value "foo"}] :value)
;; => "foo"The frame capture will be enhanced to include hiccup:
{:state state-map ; The normalized state
:tree denormalized-tree ; The props tree
:hiccup rendered-hiccup ; NEW: The hiccup DOM structure
:timestamp (System/currentTimeMillis)}-
Create
dom_hiccup.clj✅HiccupElement,HiccupText,HiccupFragmentrecords withIHiccupElementprotocol- All HTML/SVG tag functions (div, span, button, etc.) generated via macros
- Full support for CSS shorthand (
:.class#id),:classesattribute render-to-hiccupfor component rendering- Hiccup tree helpers:
find-element-by-id,find-elements,find-elements-by-tag,find-elements-by-class,element-text,element-attr,hiccup-attrs,hiccup-children
-
Add Hiccup Helpers to
clj_testing.clj✅last-hiccup/hiccup-at-render- Access hiccup from framesfind-in-hiccup/find-all-in-hiccup- Find elements in rendered hiccupfind-by-tag/find-by-class- Convenience findersclick-on!- Locate and invoke onClick handlerinvoke-handler!- Generic handler invocationtype-into!- Simulate typing (invokes onChange)submit-form!- Simulate form submission (invokes onSubmit)assert-element-exists/assert-element-text/assert-element-attr- Test assertions
-
Update Frame Capture ✅
- Frames now include
:hiccupkey with rendered DOM structure - Real function handlers preserved (not stringified)
- Frames now include
-
Tests ✅
- 8 test specifications with 75 assertions
- Basic tag function tests
- CSS shorthand and :classes tests
- Component rendering tests
- click-on!, type-into!, submit-form! integration tests
- Assertion helper tests
- Form State Testing - Add form validation test examples
- Documentation Generation - Generate API docs from docstrings
- Performance Testing - Benchmark sync vs async performance