Xleth is a Windows-only application with three build layers:
| Layer | Tech | Build tool |
|---|---|---|
| Engine | C++20, JUCE, FFmpeg, D3D11/OpenGL | CMake + MSVC (VS 2022) |
| Bridge | Node-API addon (xleth_native.node) statically linking the engine |
cmake-js |
| UI | Electron + React 18 + Vite | npm |
Every pull request and every push to main runs .github/workflows/ci.yml, which has two jobs (both pinned to windows-2022 — the VS 2022 generator and its bundled CMake 3.x are load-bearing):
- Engine + bridge (MSVC x64) — gating. Configures and builds the full CMake project (engine libraries, scanner, editor-host, and the engine test executables), then builds the bridge addon with cmake-js and verifies
xleth_native.nodewas produced. If this job is red, the PR broke the native build. - UI (Vite build + vitest) — the Vite production build is gating; the vitest suite runs on every PR but is currently non-blocking because it has known pre-existing failures (see
AUDIT.md§3.5). A red vitest step is visible in the job log but does not fail the workflow. Once the suite is green, thecontinue-on-errorline in the workflow should be removed so tests gate merges too.
The first CI run on a cold cache is slow (~1–2 h) because vcpkg compiles FFmpeg from source; compiled packages are stored in the GitHub Actions cache, so subsequent runs skip that.
Prerequisites:
- Visual Studio 2022 with the C++ workload. Use the VS-bundled CMake (3.x line) — CMake 4.x fails to configure some fetched dependencies, which is why CI pins it too.
- vcpkg with the
VCPKG_ROOTenvironment variable set (dependencies are declared in vcpkg.json and installed automatically at configure time). - Node.js (CI uses Node 24).
Then, from the repo root — build.bat wraps all of these, or run them directly:
:: Engine (same as CI "Configure" + "Build" steps, or: build.bat engine)
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release --parallel
:: Bridge (same as CI, or: build.bat bridge) — run AFTER the engine build,
:: it reuses the vcpkg packages installed into build/vcpkg_installed
cd bridge
npm ci
npx cmake-js compile --CDCMAKE_BUILD_TYPE=Release
cd ..
:: UI build + tests (same as CI)
cd ui
npm ci
npm run build
npm testNotes:
- Close any running Xleth/Electron instance before rebuilding the bridge — the loaded
xleth_native.nodelocks the output file (build.batdoes this for you). - The Playwright baseline screenshots (
ui/tests/baseline) are not part of CI; they require a built app and local snapshots.
Engine (C++): all 46 test_* executables are registered with CTest. After building, run them in one command from the repo root:
ctest --test-dir build -C Release --output-on-failurebuild.bat tests (menu option 6) does exactly this. Tests execute with the repo root as working directory, matching how they were historically run.
Bridge (contract scripts): npm test in bridge/ runs every test_*.js script in the directory sequentially (each in its own node process) plus the compiled test_export_naming.exe (a C++ unit test built by the bridge's cmake-js build — it is not one of the 46 engine CTest targets). Individual scripts are still available via npm run test:phase1, npm run test:transport, etc.
UI unit tests (vitest): npm test in ui/ runs the vitest suite (vitest run, jsdom/node components). This is the suite CI runs (currently non-blocking — see "What CI checks" above).
UI Electron main-process smoke (ui/tests/smoke): a Playwright suite that launches the real Electron app (ui/main.js) — forking the engine worker and driving the actual preload/IPC/worker stack, nothing stubbed — and asserts three things: the app boots without throwing, the forked engine worker reaches "ready", and a real read-only IPC round-trip (timeline.getBPM) completes. It exists so the planned ui/main.js decomposition (AUDIT.md S5) has a baseline that fails loudly if boot, worker startup, or the IPC round-trip regresses. Because it drives the built UI, run a UI build first:
cd ui
npm run build :: produces dist/index.html (main.js loads it under XLETH_PLAYWRIGHT=1)
npm run test:smoke :: playwright test --config playwright.smoke.config.tsThis suite is separate from the vitest suite (different runner + command) and from the screenshot baseline (playwright.config.ts / ui/tests/baseline). Like the baseline, it needs a built app and is not part of CI — it requires the native xleth_native.node addon (built via the Engine + Bridge steps above) and Windows audio/GPU, so it runs locally, not on the windows-2022 runners.
The CTest/npm wiring registers the tests; it does not fix them. The following were already failing when first run through the unified commands (they fail the same way when invoked manually) and are tracked as pre-existing:
Engine (ctest, 43/47 passed, baseline 2026-07-14):
| Test | Failure |
|---|---|
test_flip_orientation_golden |
assertion failure (GPU shader golden) |
test_effects |
assertion failure |
test_reverb |
assertion failure |
test_real_render |
segfault — also hardcodes the project path C:\Users\Krasen\Desktop\XLETH\test, so it can only ever run on that machine |
test_video_flip_applier and test_frame_collector, previously listed here, now pass on the current build — in particular test_frame_collector runs Tests 1–6 to ALL TESTS PASSED and the old 0xc0000409 fail-fast crash no longer reproduces. The suite is now 47 tests because the snapshot-transition feature added test_snapshot_transition (passing).
Engine note: the FFmpeg-linked tests need the vcpkg DLLs on PATH; the CTest registration prepends <vcpkg_installed>/bin per test (ENVIRONMENT_MODIFICATION), which is what manual runs always relied on. Without it, 15 additional tests die on startup with 0xc0000135 (DLL not found). A stale test_envelope_voice_events.exe may exist in old build trees — its target was removed and it is intentionally not registered.
Bridge (npm test → run_contract_tests.js, 12/15 passed, baseline 2026-07-14):
| Script | Failure |
|---|---|
test_dynamics_viz.js |
drain.schema === 1 (got 2) — schema version mismatch |
test_midi_import.js |
expects C:\Users\Krasen\Desktop\XLETH\test.wav (missing on disk) and sampler-metadata round-trip fails (rootNote/attackMs come back undefined) |
test_pdc_live_presentation_refresh.js |
EPERM deleting its scratch copy under diagnostics\pdc-stage7c\ during cleanup |
test_patterns.js, previously listed here, now passes on the current build. test_snapshot_transition_preview_contract.js (the snapshot-transition live preview seam) passes.
bridge/test_perf_regression.js guards against a specific, previously-shipped class of bug: SyncManager::videoTick() performing blocking decoder->seekAndDecode() calls in the bridge (.node/XLETH_CORE_ONLY) build whose decoded frame nothing ever consumed there (compositor_ is always nullptr in that build — only the full-engine executable wires a real one). Each call cost 100–330ms on scattered seeks, blocking the frame-pacing thread under syncEventsMutex, and collapsed a real project's preview from 60fps to ~17fps before it was fixed in 3db1b33. It runs as part of bridge's npm test (picked up automatically, like every test_*.js script) and as a pre-push gate in push.bat.
What it does. It builds a synthetic project entirely through the addon's own RPCs at gate time — no checked-in fixture, no user files, no network: a short test clip generated with the vendored FFmpeg (vendor/ffmpeg/bin/ffmpeg.exe, testsrc2 source filter), 30 tracks with a clip spanning the whole measured playback window (comparable to the "few concurrently active" shape of the real project this bug was found on) plus enough short, off-window filler clips to reach ~1000+ total registered clips (comparable to that project's 1738). It then plays the project and samples diag_getVisualPreviewDiagnostic() — the engine's own preview diagnostics, not a hand-rolled timer — at high frequency.
What it asserts:
- Hard invariant —
counters.syncManagerDecodeCountstays exactly 0 for the whole run. This is the deterministic signal: it is incremented at the exact call site of the historical bug (SyncManager::getDecodeAttemptCount(), mirroringdecodeTimeSamples_.size()), which only exists in a build where the guarding#ifndef XLETH_CORE_ONLYaround that decode has been removed or bypassed. Any non-zero value means the bug (or something structurally identical to it) is back, regardless of how fast the fixture's own test video happens to decode. videoTicktiming budget — p95 ≤ 5ms, mean ≤ 2ms, measured fromcounters.lastVideoTickUs/avgVideoTickUs(added alongside the decode counter specifically to time thesyncManager->videoTick()call itself — the existingavgTickUs/lastTickUsfields time a later, separate stage, the compositor pipeline, which only starts aftervideoTick()has already returned). Generous headroom above the fixed baseline (0.02–0.07ms measured) so slower dev machines don't false-positive; still well below the 15–141ms range the original bug produced. This budget is secondary to the hard invariant above — on a fast, simple fixture video a resurrected bug's decode can complete well under 5ms even though it is still pure dead work, which is exactly why the invariant exists as a separate, load-independent check.- Sanity (non-vacuous run) — steady-state delivered FPS ≥ 90% of the 60fps cap, and
lastCellCount/requestCountwere both observed > 0 at least once, so the gate cannot pass by silently measuring nothing (e.g. a GPU compositor that failed to initialize, or a fixture that never actually played).
When it skips (exit 0, not a failure): no usable D3D11 device (compositorReady === false — preview FPS budgets aren't meaningful without a real compositor), or vendor/ffmpeg/bin/ffmpeg.exe is missing.
If it fails: the printed summary table shows per-stage timings, the videoTick p50/p95/max, the decode count, and delivered FPS — enough to diagnose without local repro. A non-zero syncManagerDecodeCount means something is calling decoder->seekAndDecode() from SyncManager::videoTick() again in the bridge build; check that the guard around SyncManager.cpp steps 4d–4h is still #ifndef XLETH_CORE_ONLY and still contiguous through 4f–4h (compositor upload), matching commit 3db1b33. A videoTick budget failure with syncManagerDecodeCount === 0 points at a different per-tick cost added to videoTick() (the drains, the beat-window loop itself, or something new) rather than a repeat of this specific bug.
Skipping the gate for an emergency push: set XLETH_SKIP_PERF_GATE=1 && push.bat. This skips only the pre-push check, not the commit — use it when you need to push urgently and will follow up with a real fix, not as a routine workaround for a budget the gate correctly caught.