Running Kaocha ClojureScript Tests Headless with JSDOM
Running ClojureScript tests in a headless environment can be quite fiddly, but it's definitely achievable. I recently got Kaocha CLJS2 working with JSDOM to run Shadow CLJS compiled tests on CI (and locally), and wanted to share the setup since I haven't seen much discussion about this approach.
Why This Approach?
Instead of spinning up a headless Chrome instance or requiring a dev server, this method runs tests inside Node.js with JSDOM - an almost complete browser environment that can execute scripts when properly configured. It's fewer moving parts and works well for CI environments.
Shadow CLJS Configuration
First, add a test build to your shadow-cljs.edn
:
:test-kaocha {:target :browser-test
:runner-ns kaocha.cljs2.shadow-runner
:test-dir "target/kaocha-test"
:ns-regexp ".*-test$"
:devtools {:preloads [lambdaisland.chui.remote]}}
This is pretty standard configuration. Compile the test bundle with:
npx shadow-cljs compile test-kaocha
This generates the test bundle and index.html
inside the target directory.
Setting Up Funnel
Start the funnel process using the snippet from the repo:
clojure -Sdeps '{:deps {lambdaisland/funnel {:mvn/version "1.6.93"}}}' -m lambdaisland.funnel
Keep this running - we'll need it for the test execution.
The JSDOM Wrapper Script
Here's where it gets interesting. JSDOM needs some configuration and monkey patching to work properly. Create a run-tests.mjs
file:
import fs from 'node:fs'
import {JSDOM, ResourceLoader } from 'jsdom'
class StaticResourceLoader extends ResourceLoader {
fetch(url, options) {
console.log(`request ${url}`)
if (url.startsWith('http://localhost:1818')) {
const path = url.split('http://localhost:1818').pop()
const file = fs.readFileSync(`target/kaocha-test${path}` )
return Promise.resolve(file)
}
if (url.startsWith('http://127.0.0.1:1818')) {
const path = url.split('http://127.0.0.1:1818').pop()
const file = fs.readFileSync(`target/kaocha-test${path}` )
return Promise.resolve(file)
}
return super.fetch(url, options)
}
}
const index = fs.readFileSync('target/kaocha-test/index.html').toString()
const resourceloader = new StaticResourceLoader({
proxy: "http://127.0.0.1:1818",
strictSSL: false,
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
});
const dom = new JSDOM(index, {
runScripts: 'dangerously',
resources: resourceloader,
url: 'http://localhost:1818',
storageQuota: 10000000
})
Object.defineProperty(dom.window.document, 'execCommand', {
writable: false,
value: console.log
})
Object.defineProperty(dom.window, 'TextEncoder', {
writable: true,
value: TextEncoder
})
Object.defineProperty(dom.window, 'TextDecoder', {
writable: true,
value: TextDecoder
})
Key Configuration Points
Two critical aspects of this setup:
- User Agent: Must be a real browser UA string, or several things break
- Resource Loader Path: Points to the Shadow CLJS output directory rather than running a separate dev server
Running the Tests
Start the Node wrapper script:
node run-tests.mjs
You should see Chui firing up and printing connection messages. Keep this process running.
Now run Kaocha:
bin/kaocha --config-file cljs-tests.edn
If everything is configured correctly, you'll see test output and results!
CI Integration with Make
For CI environments, I wrapped everything into a Makefile target that handles process management:
.PHONY: cljs-tests-headless
cljs-tests-headless:
npx shadow-cljs compile test-kaocha
clojure -Sdeps '{:deps {lambdaisland/funnel {:mvn/version "1.6.93"}}}' -m lambdaisland.funnel & echo $$! > .funnel.pid
node run-tests.mjs & echo $$! > .node.pid
(bin/kaocha --config-file cljs-tests.edn; echo $$? > .exit_code); \
kill `cat .funnel.pid` `cat .node.pid` 2>/dev/null || true; \
rm -f .funnel.pid .node.pid; \
exit `cat .exit_code`; rm -f .exit_code
This command:
- Compiles the test bundle
- Starts funnel and Node processes in the background
- Runs Kaocha and captures its exit code
- Cleans up all processes
- Returns the proper exit code for CI
Run it with:
make cljs-tests-headless
Security Considerations
Important: This approach runs JavaScript with runScripts: 'dangerously'
. If your tests include code from untrusted sources or user input, this could be a security risk. Only use this method with code you trust.
Troubleshooting
You may encounter missing browser globals that need monkey patching. The script above handles TextEncoder
, TextDecoder
, and execCommand
, but you might need to add others depending on your test requirements.
Conclusion
While this setup requires some configuration, it provides a reliable way to run ClojureScript tests in CI without the overhead of browser automation tools. The combination of Kaocha CLJS2, Shadow CLJS, and JSDOM creates a robust testing environment that works well for most ClojureScript codebases.
Thanks to the teams behind Kaocha, Shadow CLJS, and Funnel for making this possible!