fix(demoscene): rename metaballs v2 fieldStrength to v3 strength (static-fallback regression) - #18
Conversation
After the Demoscene API v3 migration, every portfolio accent fell back to its
static fallback at runtime, even though npm test stayed green.
Root cause: API v3 added a strict recursive assertKnownKeys() in the descriptor
resolver that throws RangeError("Unknown option: …") on any config key absent
from an effect's configDefaults. The metaballs skin still emitted the v2 key
metaballs.field.fieldStrength (the v3 name is field.strength), so
Demoscene.metaballs(canvas, descriptor) threw. main.js mounts all effects in one
unguarded forEach, so that single throw aborted mountEffects and dropped the
whole site to the demoscene-fallback path — hence the static coloured "blobs"
for metaballs, plasma and mandelbrot alike.
plasma and mandelbrot descriptors were already valid under v3 (verified against
the live production bundle); only metaballs needed the rename.
The existing descriptor test did not catch this because it re-implemented the
v3 resolver's detectLegacy() from memory and only checked descriptor shape, never
whether the config KEYS the skins emit are still recognised by the bundle's
configDefaults.
Changes:
- effect-skins.js: metaballs.field.fieldStrength -> metaballs.field.strength.
- tests/site-smoke.test.mjs: assert the v3 strength key (and that the v2 key is
gone).
- tests/demoscene-bundle.test.mjs: new integration test that fetches the REAL
production v3 bundle, loads it verbatim, and drives the actual factory
Demoscene.<name>(canvas, descriptor) for every effect/theme/mobile combo — no
Demoscene mock. Also pins the rejection of the v2 fieldStrength key so this
exact regression cannot return.
Verified: npm test 26/26; python unittest 41/41 untouched. Bundle and the
demoscene_classics repo are out of scope and were not modified.
Co-Authored-By: Claude <noreply@anthropic.com>
The bundle integration test used test.message(...) to fail open when the production bundle couldn't be fetched. node:test has no test.message() API, so that branch threw TypeError and FAILED the build on any no-egress runner instead of skipping — the opposite of the intended graceful degradation. Switch both tests to the callback form (t) and t.skip(...), which is the real skip API. Verified: online 26/26 pass; offline fetch-blocked → 2 skipped, 0 fail. Co-Authored-By: Claude <noreply@anthropic.com>
…le test Record the security trade-off of executing the fetched production bundle via node:vm in the test header: node:vm is not a sandbox, so the bundle runs with npm test's full privileges. This is accepted because the bundle is first-party over HTTPS and PR runs have no secrets; hardening the post-merge publish.yml path (hash-pinned fixture or isolated no-secrets canary job) is a follow-up, not a blocker for the regression fix. No behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
📋 Review summary — all cyclesReviewed locally in local mode (
Core regression fix ( Totals: 2 FIX resolved (test-skip bug fixed; vm-risk documented+tracked), 1 SKIP, 1 residual security risk → follow-up #19 (author-accepted for this PR). Verification: Merge is yours to trigger (local mode does not auto-merge). |
Problem
After the Demoscene API v3 migration landed (
Migrate Demoscene integration from API v2 to v3#17), all animated accents on the portfolio (metaballs / plasma / mandelbrot) fell into their static fallback at runtime — static coloured blobs instead of animation.npm teststayed green, so the regression slipped through.Root cause (verified against the live production bundle)
API v3 added a strict recursive
assertKnownKeys()in the descriptor resolver (resolver.js): any config key — including nested ones — that is absent from an effect'sconfigDefaultsthrowsRangeError("Unknown option: …").The metaballs skin in
effect-skins.jsstill emitted the v2 keymetaballs.field.fieldStrength. In v3 that peak field scalar was renamed tofield.strength(seeMETABALLS_DEFAULTS). SoDemoscene.metaballs(canvas, descriptor)threw.main.jsmounts all effects in a single unguardedforEach(mountEffects). That one throw aborted the whole pass, the surroundingtry/catchinloadDemosceneset the rootdemoscene-fallbackclass, and CSS rendered the static fallback for every accent — hence metaballs, plasma and mandelbrot all going static together.Why the test suite missed it
The existing descriptor test (
site-smoke.test.mjs) re-implemented the v3 resolver'sdetectLegacy()from memory, so it only validated descriptor shape ({ skin, surface, device, config }) — never whether the config keys the skins emit are still recognised by the bundle'sconfigDefaults. A phantomVALID_CONFIGallow-list even includedfieldas always-valid, so the bogus nestedfield.fieldStrengthkey sailed through.Fix
effect-skins.jsmetaballs.field.fieldStrength→metaballs.field.strength(the v3 configDefaults name). Values3.4(desktop) /0.72(mobile) are unchanged.tests/site-smoke.test.mjsstrengthkey and that the v2fieldStrengthkey is gone.tests/demoscene-bundle.test.mjsmanifest.json+demoscene.js, loads the bundle verbatim, and drives the actual factoryDemoscene.<name>(canvas, descriptor)for every effect × theme × mobile combination —factory()must not throw and must return a controller withstart/stop/renderOnce. Also pins rejection of the exact v2fieldStrengthkey so this regression cannot return. Fails open (skip) when the CI runner has no network egress.Scope: only this repo. The
demoscene_classicsbundle and repo are external and were not modified.Verification
metaballs→RangeError: Unknown option: metaballs.field.fieldStrength;plasma/mandelbrot→ build controllers.metaballs … factory threw: Unknown option: metaballs.field.fieldStrength) when the fix is reverted — i.e. the guard works.npm test: 26/26 green (was 24; +2 new).python3 -m unittest discover -s profile/tests: 41/41 untouched.Risk / follow-ups
main.jsstill mounts all effects in one unguardedforEach, so any future single-effect config error will again collapse the whole site. A follow-up could mount each effect in its owntry/catchso one bad effect degrades to its own fallback rather than the site's. Out of scope for this regression fix.