kaocha

dazld 2025-08-29T14:59:39.361019Z

I can’t see many people talking about using kaocha cljs2 and jsdom to run headless, shadow cljs compiled tests on CI - or even locally if you prefer - so just want to share that the warning about it being quite fiddly is well justified. However - it does work, and thank you to the team who built all this for sharing it. I’ll put a couple of snippets in the thread in case there’s someone else out there struggling - and, if some of this is easier done in a different way, I’d love to know too.

💯 1
❤️ 1
plexus 2025-10-01T12:36:09.773679Z

Thanks @dazld, good stuff! I put together this repo with examples a while ago to help people, maybe we need to make that a bit more visible. If you'd like to add an extra example there with your setup that'd be really appreciated. https://github.com/plexus/cljs-test-example

👌 1
dazld 2025-08-31T09:36:18.171739Z

I’m curious if the node part is even really necessary these days - given there is an out of the box chrome --headless option

dazld 2025-08-31T09:36:27.908309Z

would love to know what others are doing here.

dazld 2025-08-29T15:10:05.949729Z

First up, I added a kaocha build to the shadow-cljs config, something like:

:test-kaocha {:target    :browser-test
              :runner-ns kaocha.cljs2.shadow-runner
              :test-dir  "target/kaocha-test"
              :ns-regexp ".*-test$"
              :devtools  {:preloads  [lambdaisland.chui.remote]}}
which is pretty vanilla. The build is run as usual with
npx shadow-cljs compile test-kaocha
…which generates the test bundle and index.html inside target. We’ll need those shortly. Next, you run funnel directly via the snippet provided helpfully on the repo:
clojure -Sdeps '{:deps {lambdaisland/funnel {:mvn/version "1.6.93"}}}' -m lambdaisland.funnel
This stays open until you close it - which again, we’ll come back to in a sec. Given we don’t want to load a headless chrome or similar (which you could, but would need the dev server running etc), I opted to run the tests inside node, with JSDom. JSDom is an almost complete browser environment inside node - that can execute scripts if you give it permission. I looked at some alternatives like linkedom - but that one explicitly doesn’t let you run scripts, so - ended up staying with JSDom. Out of the box, JSDom won’t work - it needs a bit more configuration and some monkey patching of the provided browser globals to get it working. It’s missing TextEncoder and execCommand, for example. You may have to do more of this if you use any other missing browser methods. The node wrapper script looks like this:
import fs from 'node:fs'
import {JSDOM, ResourceLoader } from 'jsdom'

class StaticResourceLoader extends  ResourceLoader {
  fetch(url, options) {
    console.log(`request ${url}`)

    if (url.startsWith('')) {
      const path = url.split('').pop()
      const file = fs.readFileSync(`target/kaocha-test${path}` )
      return Promise.resolve(file)
    }
    if (url.startsWith('')) {
      const path = url.split('').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: "",
  strictSSL: false,
  userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15",
});

const dom = new JSDOM(index, {
  runScripts: 'dangerously',
  resources: resourceloader,
  url: '',
  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
})
I guess you could make that smaller, but its easy to read. There are two important parts here - first is the userAgent - it needs to be a real browser UA, or a few things seem to break. The second is that the path in the resourceloader needs to be pointed at the right place to find the generated shadow cljs output. You could, I suppose, run the standard shadow cljs HTTP dev server, but this seems like fewer moving parts. Let’s run this node script with:
node run-tests.mjs
If all is well, you should see chui firing up and printing messages about waiting for a connection, plus any other console logs you may print. Node will also need to keep running until we have completed running kaocha - so we’ll need a solution for this and the others which we’ll get to in a sec. If you have all of those processes running still, you can now run:
bin/kaocha --config-file cljs-tests.edn
(or wherever your cljs2 tests are defined) ..and you should see test output and results! 🎉 For running in CI, I wrapped up all the services and processes into a Makefile command, like this:
.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
here we’re keeping funnel & node running in background processes until kaocha completes, then killing them. We capture the exit code from kaocha, and return it so the invoker can see if the tests were successful or not. Run the make command with:
make cljs-tests-headless
and you should see everything spun up, test run, results given and process spun down in one command.

dazld 2025-08-29T15:16:04.383409Z

If you are at all worried about trusting the scripts you may run (ie, they come from the open web, or untrusted user input) then you really should not use this approach - as people could do nasty things if their script ends up running inside this process - so do consider if this is the right approach before looking at it.

dazld 2025-08-29T15:27:16.581169Z

I wrote all this nonsense up into a blog post too: https://flarework.com/posts-output/2025-08-29-headless-kaocha-shadow-cljs