Internals

Testing

Everything is tested with Node's built-in runner (node:test, node:assert): no test framework, no packages. Hardware and outside services are replaced by small pretend servers that speak the real protocols on 127.0.0.1, so the tests check real bytes on real sockets.

CommandWhatNeeds
npm testevery unit and integration test (node --test)Node 20+
node --test packages/engine/test/live.test.jsone file
npm run test:browserscripts/e2e-browser.js: the web app in Chromium against a real serverPlaywright (CI also installs ffmpeg)
npm run site && npm run test:demoscripts/e2e-demo.js: the built online demo as static filesPlaywright
npm run test:firmwarefirmware/test/run.sh: Tempolight Lite's C++ core on the hostg++ or clang++
npm run smokescripts/smoke.js: start a build, check it answers, stop it
node scripts/bench.jsCPU per part of the server
npm run eval:beatscripts/beat-eval.js: how well the live analyser finds the beat
npm run eval:feelscripts/feel-eval.js: how well the lights follow a song

CI (.github/workflows/ci.yml) runs npm test on Linux, Windows and macOS with Node 20, 22 and 24, starts the server and checks /api/health and /, then runs both browser tests on Linux. firmware.yml runs the firmware host tests with g++ (plus the usermod against WLED) and clang++.

Unit and integration tests: npm test

node --test with no arguments runs every *.test.js and every file in a test/ folder. Files in test/ that contain no tests (the pretend servers, synth.js) are imported by others and pass trivially.

FolderFilesCovers
packages/engine/testengine.test.js, live.test.js, memory.test.js, timeline.test.js, tonal.test.js, beat.test.js, feel.test.jsanalysis of synthetic songs, the layout, every effect (finite colours, the 4 ms budget for 1,000 LEDs), custom effects, the live analyser, the director, song memory, shows and timelines, key and mood, and no regression in beat tracking or feel (see Sync measurements)
packages/outputs/testoutputs.test.js, discover.test.js, dmx.test.js, dtls.test.js, govee.test.js, lights.test.js, openrgb.test.js, smartlights.test.jspackets byte by byte (DDP, audio sync, E1.31, Art-Net, segments), mDNS parsing and scans, DMX profiles, the DTLS client against a separate server and the published PRF test vector, every smart-light protocol
packages/server/testserver.test.js, lights.test.js, latency.test.js, perf.test.js, pro.test.js, home.test.js, timeline.test.js, mqtt.test.js, homekit.test.js, nowplaying.test.js, spotify.test.js, integrations.test.js, streamdeck.test.js, update.test.js, wifi.test.js, env-settings.test.jsthe whole app through HTTP and WebSocket: routes, the PIN lock, Origin checks, devices of every kind, the frame loop's output, latency and performance, DMX, MIDI clock and Link, home and sleep with an injected clock, MQTT against a tiny broker, HomeKit pairing as an iPhone would, now playing with fake players, Spotify against a fake Web API, integrations against fake OBS, Twitch and Discord
apps/webcalibrate.test.js, mapping.test.js, qr.test.jsthe pure helpers of the timing fix, camera mapping and the QR code maker
scriptsbundle.test.js, release-manifests.test.js, gen-api-routes.test.jsthe one-file bundler (the bundled server starts and serves), package manifests, and that api.md documents every route

A full run takes about 3 minutes (most of it real-time audio and network tests). Timing-sensitive assertions get 3 x the budget when CI is set.

Synthetic songs

packages/engine/test/synth.js is a small deterministic synth: edm() (128 BPM, 4 bars intro, 4 bars snare-roll build-up, 8 bars drop, 2 bars outro, Am F C G), ballad(), voice(), pluck(), piece(), minorBallad(), majorPop(). Songs with known structure mean analysis can be tested without shipping audio files. (scripts/test-songs/*.py make longer WAV files for listening by hand.)

beat-songs.js makes 15 drum songs with known beats, downbeats and kicks: house, hip-hop, half-time, drum and bass, swing, a quiet intro, a break, a tempo jump and a tempo ramp. feel-song.js makes a one-minute EDM song with known parts: intro, build, drop, breakdown, build, drop, outro, silence.

Pretend devices and services

FilePretends to be
scripts/fake-wled.jsa WLED controller: /json/info, /json/state, segments, and DDP frames (counted). startFakeWled({name, leds, httpPort, ddpPort}) for tests; npm run fake-wled -- --leds 300 --name Desk --http 18080 --ddp 4048 by hand
scripts/fake-link.jsan Ableton Link peer with a deliberately offset session clock
packages/outputs/test/fake-lights.jsLIFX, WiZ, Kasa, Yeelight and Twinkly lights, each recording what it was sent
packages/outputs/test/dtls-server.jsa DTLS 1.2 PSK server (own record and handshake code) for Hue fast mode
packages/server/test/fake-spotify.jsthe Spotify Web API on a test clock: jitter, seeks, pauses, skips, volume, timestamp behaving as it really does, audio analysis 403/fixture/404, 429 with Retry-After
packages/server/test/fixtures/what osascript, playerctl, dbus-send and PowerShell print (now playing), CS2 and Dota 2 GSI posts, a Spotify audio analysis

The server tests start the real app in-process with createApp({dataDir: <temp dir>, logger: null, discover: false, ...}) and its injection points: now (wall clock), host (pretend computer for the performance mode), nowPlaying (command runner, platform, fetch), spotify (clock), homekit (no mDNS, port), integrations (open, fetch, URLs), and wifi.

Sync measurements

Two scripts measure how well the lights stay in time with the music. They use no packages and give the same numbers on every run (Math.random is seeded while the engine runs). They always test the code as it is now, so you can run them on any branch.

Beat tracking: npm run eval:beat

scripts/beat-eval.js feeds each song to the live analyser (packages/engine/src/live.js) in 512-sample chunks at 22,050 Hz, as the runtime does. It records the beats the lights would show, the kicks the analyser hears and its tempo. A full run takes about 13 seconds.

The songs are the drum songs from beat-songs.js, edm() and majorPop() from synth.js, and your own WAV files. To add your own, put song.wav and song.json in test-audio/ at the top of the repository. The folder is ignored by git. The JSON holds the true times in seconds: {"beats": [...], "downbeats": [...], "kicks": [...]}. Only beats is required. The WAV can be 8, 16, 24 or 32-bit, or float, at any sample rate.

For each song and the mean it prints:

ColumnMeaning
FF-measure: shown beats within 70 ms of a true beat, from 5 s on
CMLt, AMLtbeats in time and in a steady run; AMLt also accepts double, half and off-beat tempo
err±sd msmean and spread of the timing error once locked; positive means the light is late
lock swhen the first run of 8 correct beats starts
relockthe same, from the moment the music comes back after a break or a tempo change
tempo ok/oct/othshare of the time the tempo is right, an octave off (half, double, 2/3, ...), or wrong
kick R/Pkick recall and precision (60 ms)
downbtof the downbeats that got a shown beat, how many were shown as beat 1
cpu ms/sanalyser CPU time per second of audio

Options: --json, --song <part of a name>, and --baseline check or --baseline write. LIVE=path/to/live.js tests another analyser file instead (a prototype).

Feel: npm run eval:feel

scripts/feel-eval.js plays feel-song.js through the real engine on the example room at 60 frames a second. It runs twice. Whole-song mode is preAnalyseCore, then songView, the director and the effects. Live mode is the live analyser, then the director and the effects. It measures the brightness of every frame against the known song. A run takes about 5 seconds.

MetricMeaning
analysis BPMis preAnalyseCore's tempo within 2% of the truth
kick contrastin the drops: (peak in the 120 ms after a kick - low in the 120 ms before) / peak; median and 10th percentile
kick-to-light lagwhen the biggest brightness rise near a kick happens
changes on downbeat, on 4-bar lineshare of effect changes within -30/+75 ms of a bar line, or of a 4-bar line
shorter than a bareffects that showed for less than one bar
drop / breakdownmean brightness of the drops divided by the breakdown
pre-drop dipthe last 0.4 s before a drop against the 1.5 s before that
drop impactthe brightest frame in the first 0.25 s of a drop against the drop's typical brightness
live drop delay, false dropshow late live mode calls each drop; switches into "drop" that are not a drop

--effects also lists which effect showed when, and the live song parts.

It ends with a report of the roadmap's targets, each PASS or FAIL. The current code does not meet them yet. The report shows progress; it is not a test.

TargetAim
analysis BPMwithin 2%
drop kick contrastmedian at least 0.40, 10th percentile at least 0.25
effect changes on a downbeatat least 90%
effect changes on a 4-bar lineat least 75%
effects shorter than one barnone
drop / breakdown brightnessat least 1.8
pre-drop dipat most 0.7
live drop delayat most 150 ms

Baselines

packages/engine/test/baselines/beat.json and feel.json hold the numbers measured on main. beat.test.js and feel.test.js (part of npm test) fail when the code gets worse than them:

  • beat: the mean F may drop by at most 0.02, and no song's F by more than 0.1.
  • feel: each metric has a margin, for example 0.05 for kick contrast, 10 points for changes on a downbeat, 150 ms for the live drop delay. A drop that was found before and is now missed fails.

After an intentional improvement, write new baselines and commit them with the change:

npm run eval:beat -- --baseline write
npm run eval:feel -- --baseline write

--baseline check prints the same comparison as the tests, with every song or metric. Your own WAV files are never written to the baseline.

Browser tests

  • scripts/e2e-browser.js starts two pretend WLED controllers and a Tempolight server, then clicks through every page like a user: add devices, lay out the room, play the demo song (apps/playground/demo.mp3), listen to Chromium's fake microphone, live pads, effects, scenes, schedules, the lock. Needs Playwright (npm i -g playwright && npx playwright install chromium; CI runs it with NODE_PATH=$(npm root -g)).
  • scripts/e2e-demo.js serves dist/site (from npm run site) as plain static files, no Tempolight server, and checks the demo works as Cloudflare serves it: the in-browser core and runtime, pretend controllers, the service worker.
  • scripts/screenshots.js reuses the same set-up to photograph each page for docs/images.

Firmware host tests

firmware/test/run.sh compiles Tempolight Lite's core (firmware/core/tempolight_lite.cpp) with the C++17 compiler it finds (CXX to choose; address and undefined-behaviour sanitizers when available) and runs test/test_lite.cpp: the analysis and effects against synthetic songs fed through an emulation of WLED AudioReactive's GEQ (test/synth.h). With WLED_DIR set to a WLED checkout it also compiles the usermod against a small WLED stub (test/wled_stub, using WLED's bundled ArduinoJson) and runs a smoke test. No ESP32 needed. See tempolight-lite.md.

Bench and smoke

node scripts/bench.js [--seconds 20] [--leds 700] [--songs 12] [--mode auto|full|lite] [--speed 1] [--json] runs the real server and runtime headless with pretend DDP controllers, a synthetic song fed through the live analyser in real time, remembered songs to recognise, browsers watching the preview and phones as lights. It reports CPU milliseconds per second for each part (analysis, memory, render, output, broadcast; 1,000 ms/s is one core), the worst frame and the p99. A frame longer than the frame time (16.7 ms at 60 fps) is a visible stutter.

node scripts/smoke.js <command...> starts a build on a free port with a throwaway data folder, checks /api/health, the web app, /api/state and /api/update, and stops it. It takes any way of starting Tempolight:

node scripts/smoke.js node packages/server/src/index.js   # the sources (npm run smoke)
node scripts/smoke.js node dist/tempolight.cjs            # the bundled script (scripts/bundle.js)
node scripts/smoke.js dist/tempolight-linux-x64           # the one-file app (scripts/build-sea.js --smoke)

Docs checks

  • node scripts/gen-api-routes.js --check docs/api.md (also part of npm test through scripts/gen-api-routes.test.js): the route table in api.md matches the code and every route is described. After adding a route, run node scripts/gen-api-routes.js --write docs/api.md and describe it in its section.
  • npm run docs regenerates effects.md from the engine.
Edit on GitHub