The Spice Web Client is plain HTML5/JavaScript with no build step and no npm
dependencies. The unit-test suite (unittest/) runs in a Docker-only,
headless-Chromium environment — you do not need a local Node installation.
Everything below assumes you are at the repository root.
- Docker (with a working
docker build). - That's it. The test stack ships its own vendored libraries in
unittest/vendor/(jQuery, QUnit-style runner, chai, sinon) — there is nonpm install.
docker build -t spice-web-client-dev .The image (node:24-bookworm-slim + Chromium + fonts) does not contain the
repository. You mount your checkout at runtime, so edits to tests are picked up
immediately without rebuilding.
# full suite (prints a per-test PASS/FAIL stream, then a summary + RESULT line)
docker run --rm -v "$PWD":/app spice-web-client-dev
# only tests whose name contains a substring (case-insensitive)
docker run --rm -v "$PWD":/app spice-web-client-dev --filter queue
# list every registered test and exit
docker run --rm -v "$PWD":/app spice-web-client-dev --list
# use a specific browser binary (rarely needed inside the image)
docker run --rm -v "$PWD":/app spice-web-client-dev --browser /usr/bin/chromium
# longer overall deadline (default 300s); also settable via env
docker run --rm -v "$PWD":/app spice-web-client-dev --timeout 600000
TEST_TIMEOUT_MS=600000 docker run --rm -v "$PWD":/app spice-web-client-dev
# drop into a shell in the environment
docker run --rm -it -v "$PWD":/app --entrypoint sh spice-web-client-dev
# run an arbitrary node script against the mounted repo
docker run --rm -v "$PWD":/app --entrypoint node spice-web-client-dev -e 'console.log("hi")'Note:
--filtermatches any part of a test name, including the suite prefix. E.g.--filter Graphichits both theGraphic suiteand theGraphicTestsuites. Use a distinctive substring to isolate a single test.
| Code | Meaning |
|---|---|
0 |
all tests passed |
1 |
one or more tests failed |
2 |
harness/infrastructure error (no browser, no tests registered, timeout, …) |
tools/run-tests.mjs drives the whole run with zero npm dependencies:
- Starts a tiny static file server (
tools/http-server.mjs) on an ephemeral port bound to127.0.0.1. - Launches headless Chromium against
http://127.0.0.1:<port>/unittest/index.html, in a fresh temp profile (/tmp/spice-web-client-*). - Talks to Chromium over its DevTools (CDP) WebSocket using Node's built-in
WebSocket. - Reports the result that
unittest/runner.jspublishes.
The primary signal is the runner's summary line on the console stream:
tests: N passed: N failed: N skipped: N [filtered out: N] time: Nms
This is matched from the buffered Runtime.consoleAPICalled events. It is used
on purpose because, after a finished run, the page can go unresponsive to
Runtime.evaluate; the console events are still delivered. Reading
window.__wdi_test_results over CDP is kept only as a fallback (and is the
primary path for --list mode). If neither ever appears, the run fails with a
harness timeout (exit 2).
A safety-net timer forces a process exit at TEST_TIMEOUT_MS + 15s regardless
of state, so a wedged CDP session cannot hang the driver forever.
- Per-test timeout is fixed in
unittest/runner.jsat 10000 ms (TEST_TIMEOUT_MSthere). A test that neither callsdone()nor throws within 10 s is marked failed ("test timed out after 10000ms"). - The suite-level
this.timeout(...)calls inside test files are not honored by the custom runner — the 10 s per-test limit applies. TEST_TIMEOUT_MS(env, default 300000 ms) is the overall driver deadline, distinct from the per-test limit.
These are the sharp edges hit while building this suite. They are documented so a future editor does not "simplify" them back into a broken state.
-
sinon.test+ sync tests leak fake timers/XHR.sinon.testtreats a trailing function argument as an asyncdone, deferring itsverifyAndRestore()(which restores fake timers + the fake XHR server) to a wrapper that a sync body never calls. The runner therefore handsdoneFnto a test only when the test genuinely takes a callback (t.fn.length > 0). Seeunittest/runner.jsrun(). -
globalis undefined in Chromium. UseglobalThis. In headless Chromium there is no bareglobal; the app's globalsetTimeout/clearTimeoutresolve againstglobalThis, so timer stubs/mocks must targetglobalThis, neverglobal. -
Socket URIs use the page port, not the config port. The app's
wdi.Utils.generateWebSocketUrlbuildsws(s)://<host>:<document.location.port>/?ver=2&token=<config.port>. The path port is the harness's ephemeral HTTP port (changes every run);config.portonly appears as thetoken=query param. Tests asserting these URIs must build the expectation fromdocument.location.port, not a hardcoded port. -
wdi.exceptionHandlingdecides throw-vs-log. When true, the app swallows errors throughprocessExceptionHandled; when false it throws. A test that wants the throwing path must setwdi.exceptionHandling = falsein setup and restore it in teardown. -
EventObject.removeEventsets the key toundefined, it does not delete it.this.eyeEvents[name] = undefined;— so assert the value isundefined, not that the key is absent ('name' in objis stilltrue). -
new ArrayBuffer([1,2,3,4])is a zero-byte buffer. TheArrayBufferconstructor takes a byte length, not an array —Number([…])isNaN→0. Code that then runsnew ImageData(u8, w, h)throwsInvalidStateError: The input data has zero elements.Build the buffer with the real byte count (w*h*4). -
A stubbed accessor returning an undefined free variable only throws when called. In
graphictest.test.js,getCanvas()originally returned an undeclaredcanvasOrigin; it was latent because onlydrawAlphaBlendactually callsclientGui.getCanvas(). The fix is to return the real canvas element (ctxOrigin.canvas). When a test times out rather than fails, suspect a swallowed exception in an async callback (e.g. inside a jQuerydone) that preventsdone()from being called. -
Shared prototype state in
DisplayPreProcess. The legacy$.spcExtend()copies prototype arrays (consumers/idleConsumers/queued/inProcess) by reference, so instances share one pool.graphictest.test.jsresets this in teardown or a previous test's consumer fires inside the wrong window.
- The driver kills only its own Chromium (by process group,
process.kill(-pid, 'SIGKILL')) and removes its own temp profile. It never touches unrelated browsers. - When manually cleaning up between runs, target only the harness artifacts:
processes matching
/tmp/spice-web-client-*and containers based onspice-web-client-dev. Do not kill the user's own browser crashpad/handlers.
- App code is read-only for tests.
lib/,network/,application/,process/are the product. All fixes in this project are made on the test side (unittest/*) or the harness (tools/*). If a test fails because the app genuinely misbehaves, that is an app bug to report — do not edit the app to make the test pass, and do not relax a test's assertions just to green it.