Cadence is built test-first. Every behavior change lands with a test that fails before the change and passes after it. This document describes the TDD loop and how to run each suite locally and in CI.
All versions are pinned deterministically (see
versioning-policy.md): npm exact + npm ci, .NET via
Central Package Management + committed packages.lock.json, and every GitHub
Action pinned to a full commit SHA.
1. RED Write a test that captures the desired behavior. Run it; watch it fail
for the right reason (asserting real behavior, not a typo).
2. GREEN Write the minimum code to make the test pass. Run it; watch it pass.
3. REFACTOR Clean up with the test as a safety net. Keep it green.
Keep tests meaningful, not tautological: assert observable behavior
(rendered output, HTTP status/shape, coverage of real code paths), never
expect(true).toBe(true).
| Suite | Tech | Where | CI job |
|---|---|---|---|
| Web unit + coverage gate | Vitest + v8 | apps/web/src/**/*.test.tsx |
web |
| Web e2e smoke | Playwright | apps/web/e2e/smoke.spec.ts |
web-e2e |
| Web auth e2e | Playwright | apps/web/e2e/auth.spec.ts |
web-e2e |
| Web accessibility | Playwright + axe-core | apps/web/e2e/a11y.spec.ts, auth.spec.ts, pricing.spec.ts |
web-e2e |
| .NET unit | xUnit | tests/Cadence.Api.Tests |
dotnet |
| .NET coverage gate | coverlet.msbuild | tests/Cadence.Api.Tests |
dotnet-coverage |
| .NET integration | Aspire.Hosting.Testing | tests/Cadence.Api.IntegrationTests |
dotnet-integration |
| Site accessibility | Playwright + axe-core | site/tests/a11y.spec.ts |
site |
| Site responsive + links | Playwright | site/tests/responsive.spec.ts, links.spec.ts |
site |
| SAST | CodeQL (js-ts, csharp) | — | codeql-web, codeql-dotnet |
| Dependency review | dependency-review-action | — | dependency-review |
| Secret scan | gitleaks | — | secret-scan |
| Supply-chain audit | npm audit + NuGetAudit | — | npm-audit, dotnet* |
CI (.github/workflows/ci.yml) uses a detect job to gate the stack-specific
jobs, so the pipeline stays green as the monorepo fills in. Repo-wide gates
(secret-scan, dependency-review) run independently of detect.
Run from the repo root (npm workspaces) or from apps/web.
npm ci # install exact, lockfile-pinned deps
# Unit tests
npm test # vitest run (fast feedback)
npm run test:watch --workspace @cadence/web # red/green loop while editing
# Coverage gate (fails below 80% lines/functions/branches/statements)
npm run test:coverage
# Lint / typecheck / build
npm run lint
npm run typecheck
npm run buildPlaywright serves a production build via vite preview
(apps/web/playwright.config.ts), so the smoke suite proves the shipped bundle
compiles and renders. The a11y suite runs axe-core against WCAG 2.1 A/AA in a
real browser.
# One-time: download the Chromium browser (add --with-deps in CI/Linux)
npx playwright install chromium # from apps/web
# or: npm run e2e:install --workspace @cadence/web
npm run e2e # build SPA + run smoke + a11yThe coverage thresholds live in apps/web/vite.config.ts (test.coverage).
Vitest v8 includes every file matched by coverage.include (src/**) in the
denominator, so new untested source drags coverage down and fails the gate.
Client-side format interop lives in apps/web/src/composer/formats and is
documented in share.md. Every format has a round-trip unit test
(model → format → model preserves pitch/start/duration + tempo within tolerance):
projectFile.test.ts— portable.cadence.jsonenvelope round-trip.musicxml.test.ts— MusicXMLscore-partwisesubset (velocity is not carried by notation; restored to the model default on import).audioExport.test.ts— pureencodeWav+ a mocked offline renderer, so the WAV path runs under jsdom and asserts a non-empty file of the expected duration.share.test.ts— URL-fragment snapshot encode/decode + file fallback.
Malformed input for each importer throws a typed error and surfaces a friendly UI
status (mirrors the existing MidiImportError pattern). audio/offlineRender.ts
binds to Tone.Offline (Web Audio) and is coverage-excluded — it is exercised in the
browser/e2e, not jsdom. The composer.spec.ts e2e adds export → re-import and
share-link round-trips. No new runtime dependencies were added, so the npm-audit
surface and package-lock.json are unchanged.
Freemium UI and the free-tier watermark (issue #8) are covered by:
composer/formats/audioWatermark.test.ts— the pure watermark function: asserts added energy at the watermark spec for free exports and byte-identical passthrough (same references) for paid.billing/entitlementsClient.test.ts— the typed billing client (getEntitlements,startCheckout,openPortal) over an injectedfetch.billing/PricingPage.test.tsx+billing/useEntitlements.test.ts— entitlement-driven UI state and the checkout CTA calling the API (mocked).e2e/pricing.spec.ts— Playwright + axe on the pricing page with mocked billing calls (no live Stripe). Seebilling-setup.md.
The Plugin SDK (apps/web/src/composer/plugins) generalizes the composer's
built-in registries (instruments, formats, AI providers, effects) behind one
typed, in-process host. It is test-first and adds zero runtime dependencies —
manifest validation is hand-rolled in the persistence.ts style, so the audit
surface and package-lock.json are unchanged. See plugins.md for
the authoring guide and the reference plugin.
manifest.test.ts—validateManifestaccepts well-formed manifests and throws a typedPluginManifestErrorfor each malformed shape (mirrorsMidiImportError/ProjectFileError).host.test.ts— register / activate / dispose lifecycle, duplicate-id rejection,{ override: true }last-wins, and active-only contribution aggregation.preferences.test.ts— versioned localStorage round-trip + migration/coercion of enabled plugins, keybindings, and panel visibility.builtins/*.test.ts+resolveAssistant.test.ts— the dogfooded built-ins (instruments, formats, AI providers, one effect) resolve through the host.engineEffect.test.ts— an enabled effect contribution is inserted into the engine's master chain; disabled effects leave the signal path untouched.keybindings.test.ts,usePlugins.test.tsx,PluginsPanel.test.tsx— the React glue: prefs-driven enable/disable, global shortcut dispatch, and the accessible Extensions UI.examples/helloPlugin.test.tsx— the reference plugin end to end (instrument + text exporter + command + panel).
plugins.spec.ts (Playwright) enables the reference plugin from the production
build, runs its contributed command, asserts persistence across reload, and scans
the new UI with axe. The AI worker code-split is preserved: the SDK and its
built-ins import only tone (already in the main chunk), never @magenta/music
or @tensorflow/tfjs, so those stay in the worker-loaded chunks.
platformCapabilities.test.tsdeterministically covers Mac and touch-iPad keyboard labels, real primary/available pointer media-query combinations, SSR, standalone modes, connectivity transitions, and missing browser APIs.useMobileStudioLayout.test.tsxinjects capability snapshots to preserve the existing narrow-or-coarse mobile layout behavior.keybindings.test.tslocks the existing Cmd/Option and Ctrl/Alt output.pwa.spec.tsruns the production service worker, validates the manifest and cache policy, and proves warmed routes reopen offline.
The standalone stems UI (apps/web/src/stems, issue #10 Phase 1 — not the
composer) is covered by:
stems/stemsClient.test.ts— the typed client over an injectedfetch: raw-body upload, the402/413/415/401→StemsErrormapping, job read/list, and download-URL resolution against the base URL.stems/StemsPage.test.tsx— entitlement-gated UI state (anonymous sign-in prompt, free-tier upgrade CTA, entitled uploader), the upload flow, polling an in-flight job to completion, completed-stem preview/download rendering, and failed-job + load-error alerts.e2e/stems.spec.ts— Playwright + axe on both the gated free-tier surface and the entitled upload → downloadable-stems surface, with all/api/**calls mocked (no worker/model in e2e).
No new runtime dependencies were added (the UI uses fetch and the native
<audio> element), so the npm-audit surface and package-lock.json are unchanged.
dotnet restore
dotnet build -c Release
# Fast unit tests (skips container-booting integration tests)
dotnet test -c Release --filter "Category!=Integration"
# Coverage gate (line + method >= 80%, enforced by coverlet.msbuild)
dotnet clean tests/Cadence.Api.Tests -c Release # coverlet needs a fresh build
dotnet test tests/Cadence.Api.Tests -c Release /p:CollectCoverage=trueThe coverage gate is configured in tests/Cadence.Api.Tests.csproj
(CollectCoverage=true). .NET minimal-API endpoints are emitted by a source
generator as [GeneratedCode]; those are excluded via ExcludeByAttribute so
the number reflects hand-written product code. Branch coverage on infrastructure
conditionals is intentionally not gated.
coverlet.msbuild only instruments a fresh build — if the build is up-to-date it produces no coverage file.
dotnet cleanfirst (CI always runs on clean runners).
tests/Cadence.Api.IntegrationTests uses Aspire.Hosting.Testing to boot the
real AppHost graph (API + Postgres + Redis + Azurite blob) and asserts the API's
contract surface — /health, /alive, /api/info, and the OpenAPI document at
/openapi/v1.json. It also drives a register → sign in → create project
round-trip that proves EF Core migrations apply and a project persists in
Postgres. A billing integration test drives a signed Stripe webhook
(checkout.session.completed → customer.subscription.updated) end-to-end and
asserts the user's entitlements flip Free → Pro against real Postgres, then
replays the event to prove idempotency. It requires a container runtime
(Docker) and is tagged [Trait("Category", "Integration")].
# Docker must be running
dotnet test tests/Cadence.Api.IntegrationTests -c Release --filter "Category=Integration"Identity, profile, and Projects behavior is covered by the dotnet unit suite
using WebApplicationFactory over a SQLite in-memory database (fast, no
container), including:
- local register/login/logout and
GET /api/auth/me, - passwordless magic-link request → verify (single-use),
- external OAuth via a mock provider handler registered only in the
Testingenvironment (no live OAuth secrets), and - Projects CRUD plus an authorization test that user A cannot read or modify
user B's projects (non-owner access returns
404), and - billing & entitlements — entitlement mapping per subscription status, gate
enforcement (free over-limit →
402, paid-only →402, paid allowed), Stripe webhook signature verification (valid/invalid) and idempotency (same event id twice = one state change), and the tier claim updating on subscription change. Tests construct Stripe test signatures/events locally — no live API call.
The EF migrations, design-time factory, and the startup migrator are excluded
from the coverage denominator ([*]Cadence.Data.Migrations.* + [ExcludeFromCodeCoverage])
because they only execute under Postgres and are exercised by the integration
suite, not the SQLite unit suite.
Stem separation (issue #10 Phase 1) is test-first across the pipeline. The
dotnet unit suite (tests/Cadence.Api.Tests) covers the pure Cadence.Data
pieces and the /api/stems endpoints over WebApplicationFactory + SQLite:
- State machine — every legal
Queued → Processing → Completed | Failedtransition and rejection of illegal ones. - Stem catalog + labeling — the fixed 7-label catalog (
bass,drums,vocals,guitar,keys,synth,other) and slug ordering. - WAV codec + band-split — PCM16 read/write round-trip and the deterministic
BandSplitStemSeparator(the hermetic default engine). - Job processor — happy path, failure capture (
Failed+ error message), and claim semantics. - Endpoints — free-tier
402(tier read from the DB, not the cookie), Pro202,415/413(size + duration)/400validation, list/get/download, IDOR (cross-owner read →404), and anonymous →401.
The Aspire integration suite (tests/Cadence.Api.IntegrationTests, Docker) boots
the real AppHost graph including the separation worker and drives the full
lifecycle against real Postgres + Azurite: register → promote to Pro via a signed
Stripe webhook → upload a WAV mix → poll the job to Completed → assert the
7-stem catalog → download each stem from Blob. It uses the band-split engine (no
Stems:ModelUri), so it needs no model download; the real model inference is
isolated behind the IStemSeparator seam. Cadence.SeparationWorker and the
Azure/ONNX I/O classes are [ExcludeFromCodeCoverage] (network/GPU glue) and the
worker is not referenced by the coverage-gated unit project.
Cadence.SeparationWorker uses a plain Microsoft.NET.Sdk with a
FrameworkReference to Microsoft.AspNetCore.App (for IHost/BackgroundService)
and a single net10.0 target — so its committed packages.lock.json is
RID-agnostic. As with Cadence.AppHost, adding the Azure.Storage.Blobs central
pin can surface as a win-x64/linux-x64 flip in the non-locked AppHost and
IntegrationTests locks on a full-solution restore; those two are reverted to
origin/main (they set RestoreLockedMode=false), and only real dependency
additions are committed to the four locked-mode locks (Cadence.Data,
Cadence.Api, Cadence.Api.Tests, Cadence.SeparationWorker). See
stems.md.
| Gate | What it does | Fails the build when |
|---|---|---|
| CodeQL | Static analysis for js-ts and csharp. SARIF is always produced as an artifact; upload to code scanning is gated on repo visibility (needs GitHub Advanced Security). | A query finds a high-confidence issue (once code scanning is enabled). |
| dependency-review | Reviews dependency changes on PRs. Gated on pull_request + public visibility (needs GHAS). |
A PR adds a dependency at/above high severity. |
| gitleaks | Scans the working tree for committed secrets. | Any secret is detected. |
| npm audit | Full report is informational; the gate runs at --audit-level=critical. |
A critical npm advisory is present. |
| NuGetAudit | Directory.Build.props sets NuGetAudit/NuGetAuditMode=all; NU1903/NU1904 are promoted to errors. |
A high/critical NuGet advisory (direct or transitive) is present. |
Run the audits locally:
npm audit # full advisory report
npm audit --audit-level=critical # the CI gate
dotnet restore # NuGetAudit runs on every restoreThe gitleaks ruleset is the built-in default ([extend] useDefault = true in
.gitleaks.toml); the only allowlisted paths are the secret-handling skill
docs, which intentionally list example secret patterns (e.g. the literal
string -----BEGIN PRIVATE KEY-----) rather than real secrets.
npm audit reports a set of pre-existing/tolerated high advisories. None is
critical, so the --audit-level=critical gate stays green; all are surfaced by
the informational report and tracked for a future compatible bump.
nanoid < 3.3.17(GHSA-2v37-7h3g-55p8) pulled in transitively byvite -> postcss.postcsspinsnanoid ^3.3.16and no patched3.3.xrelease exists, so there is no compatible fix.@magenta/musicaudio-resample chain —static-eval,static-module,cwise,ndarray-fft,ndarray-resample(GHSA-x9hc-rw35-f44h, GHSA-5mjw-6jrh-hvfq).@magenta/music@1.23.1(the latest release, and the one the in-browser AI assistant depends on) drags these in through its audio resampling helpers. The only "fix"npm audit fix --forceoffers is a downgrade to@magenta/music@1.1.13, a breaking change — declined. Crucially, these packages are reachable only from Magenta's audio path (core/audio_utils, used byPlayer/SoundFont/DDSP); the assistant imports just@magenta/music/esm/music_rnn(MusicRNN), which never loads that chain, so the vulnerable code is not on any runtime path we ship. Theminimist/protobufjscriticals that Magenta would otherwise introduce are pinned out via rootpackage.jsonoverrides(minimist 1.2.8,protobufjs 7.6.5).
The isolated landing + docs site (site/, own lockfile) surfaces the same
nanoid advisory through astro -> vite -> postcss -> nanoid (5 high, 0
critical). The only offered fix is a breaking Astro downgrade, so it is tracked
the same way; the site job's --audit-level=critical gate stays green.
- All GitHub Actions are pinned to full commit SHAs with a version comment.
Resolve a tag with
gh api repos/<owner>/<repo>/commits/<tag> --jq .sha. ContinuousIntegrationBuild=trueon thedotnetjob enables locked-mode restore — CI fails if a committedpackages.lock.jsonis stale. Regenerate locks withdotnet restoreand commit them when dependencies change.Cadence.AppHostandCadence.Api.IntegrationTestsopt out (RestoreLockedMode=false) because the Aspire SDK injects host-RID-specific packages a single committed lock can't satisfy cross-OS.- CodeQL for C# uses
build-mode: manual(autobuild is unreliable on .NET 10) and compilesCadence.Apiexplicitly so the extractor observes the assemblies.
Effort #9's collaboration feature is tested at every layer, security-sensitive
paths first. See collaboration.md for the design.
Web unit (Vitest) — apps/web/src/composer/model/collab/*.test.ts(x),
components/PresenceBar.test.tsx, ShareProjectButton.test.tsx:
- Convergence: two independent
Y.Docs apply interleaved/conflicting edits (insert, move, delete notes and tracks) and read back byte-for-byte identical projects — the core CRDT guarantee, asserted directly. - Sanitization: a crafted remote update carrying out-of-range/
NaNvalues is folded in and the reducer only ever sees values themigrateProject/coerceNoteseam permits (a hostile peer cannot inject illegal data). - Offline replay: updates buffered while "disconnected" exchange on reconnect and both docs converge with no lost edits.
- Deferred single-seed: only the first client to see an empty doc seeds it; joiners adopt without duplicating tracks.
- Presence: awareness add/remove reflects join/leave; the roster renders
accessible avatars with WCAG-contrast ink for both hex and
hslcolors.
.NET authz (xUnit) — tests/Cadence.Api.Tests (collaboration relay + share
CRUD). These are the fail-closed access-control assertions:
- A viewer connection's document-write frames are rejected server-side
(dropped in
RelayLoopAsyncviaYProtocol.IsWriteMessage) and never reach peers; an editor/owner write is relayed. - Malformed/undecodable frames are treated as writes and dropped for viewers.
- Unauthenticated upgrades are refused; a share token for a different project
does not grant access (
ResolveRoleAsyncreturns no role → connection denied). - Share CRUD is owner-only. Backend line + method coverage stays ≥ 80%.
E2E (Playwright + axe) — apps/web/e2e/collaboration.spec.ts, backed by a
throwaway Node relay fixture (apps/web/e2e/collab-server.mjs; a second
webServer in playwright.config.ts on VITE_COLLAB_URL):
- Two browser contexts edit the same project concurrently; both converge and each sees the other's live presence.
- A viewer link is read-only end-to-end: the viewer's attempted edit leaves the editor's note count unchanged (proving the server gate, not just UI gating).
- The collaborative composer (presence bar + remote cursors) is axe-clean.
Note-adds in the e2e specs use deterministic keyboard input (focus the note
grid, arrow to an empty pitch row, Enter) rather than pixel clicks, and assert
relative counts so they are robust to the seeded demo project.
Effort #79 adds the apps/web SPA to the AppHost as an auto-start Aspire NodeJS
resource so aspire run serves the UI. Two
properties keep this from disturbing the test/CI matrix:
-
It never enters the published output. The resource is guarded by
builder.ExecutionContext.IsRunMode, so publish mode skips it andwebnever reaches the generated deployment artifacts. Verify with:aspire publish -o ./aspire-output # the generated artifacts (Bicep) contain no "web" resource -
It stays out of the Aspire integration harness.
Aspire.Hosting.Testingboots the AppHost in run mode, so theIsRunModeguard alone would addwebthere too. The resource is therefore also gated on the repo-rootnode_modulesexisting — which the Docker-onlydotnet-integrationjob never installs — so that job boots exactly the API + backing services it asserts on, never Vite. Locally, runnpm ciat the repo root first to see the SPA underaspire run.
The dev proxy added to apps/web/vite.config.ts is a dev-server-only option
(server.proxy); Vitest and the production build ignore it, so web unit coverage
and the bundle are unchanged.
Effort #93 adds a sibling desktop resource that launches the Tauri shell
against the Aspire web endpoint. It uses WithExplicitStart(), so unlike web
it is not auto-started — it stays Not started in the dashboard until a
developer clicks Start. That, plus the same run-mode + node_modules gates as
web (and an added Rust-toolchain check), means the Docker-only
dotnet-integration job never launches it: the Aspire.Hosting.Testing harness
boots the AppHost in run mode but installs no node_modules, has no display, and
has no cargo, so the desktop resource is skipped entirely and the integration
assertions still see only the API + backing services. Publish mode omits it too,
so aspire publish artifacts contain no desktop resource.
Phase 2 (#57) reliability logic is unit-tested in Cadence.Api.Tests so the
gates stay fast and hermetic (SQLite, no Docker):
- Atomic claim (L3).
ClaimNextQueuedAsync_TwoWorkers_OnlyOneClaimsTheSameJobandAtomicClaim_WhenBothReplicasSeeQueued_ExactlyOneUpdateWinsseed one queued job, run two independent contexts (standing in for two worker replicas), and assert exactly one wins the conditionalExecuteUpdateclaim. - Stuck-
Processingrecovery (L2).ReclaimTimedOutJobsAsync_*cover requeue under max attempts,Failedonce attempts are exhausted, a fresh (unexpired) job left untouched, and queued/terminal jobs ignored.SeparationJobStateMachineTestscover the newProcessing → Queuedtransition. - Model integrity (L4).
StemModelIntegrityTestscoverhttprejection,https/file/local acceptance, a known SHA-256 vector, and checksum match/mismatch (case- andsha256:-prefix-insensitive).
The host-only glue (SeparationBackgroundService sweep loop, HttpStemModelProvider
download/verify/purge I/O) stays [ExcludeFromCodeCoverage] — its logic is factored
into the pure, tested SeparationJobProcessor and StemModelIntegrity helpers.
Server-side persistence (survive all-peers-disconnect) is unit- and
integration-tested in Cadence.Api.Tests (SQLite/in-memory, no Docker), TDD-first:
- Update-log codec (
CollabDocumentCodecTests). Round-trips a list of update payloads through the single-byte[]framing, preserves empty entries, and decodesnull/empty/truncated blobs to an empty log (fail-soft). - Hub lifecycle (
CollabHubPersistenceTests).JoinAsyncloads the saved log exactly once on first join;AppendUpdateaccumulates;SnapshotUpdateshands back a copy;LeaveAsyncsaves on last-leave, skips the save when the log is empty, and a rejoin reloads from the store. - EF store (
EfCollabDocumentStoreTests, SQLite). A missing document reads as an empty log; save/load round-trips the bytes and timestamp; a second save upserts the single(OwnerId, ProjectId)row; documents are owner-scoped (no cross-owner read). - WebSocket end-to-end (
CollaborationPersistenceTests). An editor's updates survive a full disconnect and a reconnecting client is rehydrated from the DB (the server answers its SyncStep1 with the persisted state); a fresh room answers SyncStep1 with the empty-document update; a reconnecting viewer receives the persisted state read-only. Reconnect ordering is made deterministic by polling the persisted row before rejoining (proving save-on-leave completed).
The pre-existing relay/authz tests (CollaborationRelayTests) are unchanged and
still green: none sends SyncStep1, so the reactive rehydration responder is
invisible to them. The generated EF migration is excluded from the coverage gate
([*]Cadence.Data.Migrations.*); the store, hub, endpoint, protocol, and codec
logic are covered by the tests above. Backend line + method coverage stays ≥ 80%.