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!