Skip to content

fix(demoscene): rename metaballs v2 fieldStrength to v3 strength (static-fallback regression) - #18

Merged
axisrow merged 3 commits into
mainfrom
ao/axisrow.github.io-10/fix-demoscene-v3-fieldstrength
Jul 26, 2026
Merged

fix(demoscene): rename metaballs v2 fieldStrength to v3 strength (static-fallback regression)#18
axisrow merged 3 commits into
mainfrom
ao/axisrow.github.io-10/fix-demoscene-v3-fieldstrength

Conversation

@axisrow

@axisrow axisrow commented Jul 26, 2026

Copy link
Copy Markdown
Owner

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 test stayed 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's configDefaults throws RangeError("Unknown option: …").

The metaballs skin in effect-skins.js still emitted the v2 key metaballs.field.fieldStrength. In v3 that peak field scalar was renamed to field.strength (see METABALLS_DEFAULTS). So Demoscene.metaballs(canvas, descriptor) threw.

main.js mounts all effects in a single unguarded forEach (mountEffects). That one throw aborted the whole pass, the surrounding try/catch in loadDemoscene set the root demoscene-fallback class, and CSS rendered the static fallback for every accent — hence metaballs, plasma and mandelbrot all going static together.

Diagnosis guess that turned out to be incomplete: the task brief suspected skin:'classic' / surface / device mismatches or a broadly-wrong config contract. Verified against the real bundle: the descriptor shape was already correct. plasma and mandelbrot descriptors are fully valid under v3 and build controllers cleanly — only metaballs needed fixing.

Why the test suite missed it

The existing descriptor test (site-smoke.test.mjs) re-implemented the v3 resolver's detectLegacy() 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's configDefaults. A phantom VALID_CONFIG allow-list even included field as always-valid, so the bogus nested field.fieldStrength key sailed through.

Fix

File Change
effect-skins.js metaballs.field.fieldStrengthmetaballs.field.strength (the v3 configDefaults name). Values 3.4 (desktop) / 0.72 (mobile) are unchanged.
tests/site-smoke.test.mjs Assert the v3 strength key and that the v2 fieldStrength key is gone.
tests/demoscene-bundle.test.mjs New integration test against the real production v3 bundle (no Demoscene mock): fetches manifest.json + demoscene.js, loads the bundle verbatim, and drives the actual factory Demoscene.<name>(canvas, descriptor) for every effect × theme × mobile combination — factory() must not throw and must return a controller with start/stop/renderOnce. Also pins rejection of the exact v2 fieldStrength key so this regression cannot return. Fails open (skip) when the CI runner has no network egress.

Scope: only this repo. The demoscene_classics bundle and repo are external and were not modified.

Verification

  • Reproduced the failure deterministically by loading the real bundle in a sandbox: metaballsRangeError: Unknown option: metaballs.field.fieldStrength; plasma / mandelbrot → build controllers.
  • After the fix, all three build controllers against the live bundle.
  • Confirmed the new test fails (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

  • Merging: intentionally not merged; this PR is for review.
  • main.js still mounts all effects in one unguarded forEach, so any future single-effect config error will again collapse the whole site. A follow-up could mount each effect in its own try/catch so one bad effect degrades to its own fallback rather than the site's. Out of scope for this regression fix.

axisrow and others added 3 commits July 26, 2026 14:00
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>
@axisrow

axisrow commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Reviewed locally in local mode (/review + Codex sol/xhigh), no GitHub bots pinged. PR fixes the production regression where all Demoscene effects (metaballs/plasma/mandelbrot) fell into static fallback after the API v2→v3 migration.

Cycle Reviewer Finding Verdict Resolution
1 codex Remote bundle can escape node:vm and reach CI secrets (vm is not a sandbox; escape via console.log.constructor('return process')()) FIX (HIGH, defense-in-depth) Risk accepted by author; documented in test header in 35e87cb; hardening tracked in #19
1 claude test.message() is not a real API → offline CI would throw/fail instead of skipping (claim of "silent pass" was a hallucination, but the underlying broken skip was real) FIX (real bug) Fixed in 5dfa181: switched to t.skip(...)
1 claude No assertion on controller.getStats() / cpuOnlyMandelbrot consumer branch SKIP Branch already source-covered in site-smoke.test.mjs; outside this test's config-key-contract scope
2 codex Same vm-escape finding (via setTimeout.constructor); "the config rename has no separate critical defect" FIX (same residual risk) Already addressed via #19 + inline doc; no new defect
2 claude No new findings; core fix verified, cycle-1 test fix correct

Core regression fix (effect-skins.js: metaballs.field.fieldStrengthmetaballs.field.strength): verified against the live production bundle — all three effects now build controllers; plasma/mandelbrot descriptors were already valid. This is the actual cause of the static-fallback regression.

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: npm test 26/26 green; offline fetch-blocked → 2 skipped, 0 fail; python3 -m unittest 41/41 untouched.

Merge is yours to trigger (local mode does not auto-merge).

@axisrow
axisrow merged commit 404cae8 into main Jul 26, 2026
1 check passed
@axisrow
axisrow deleted the ao/axisrow.github.io-10/fix-demoscene-v3-fieldstrength branch July 26, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant