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:

  1. User Agent: Must be a real browser UA string, or several things break
  2. 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!


Tags: testing cljs coding

Copyright © 2025 Dan Peddle RSS
Powered by Cryogen