Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 74c4f16412 feat(library): add Star-Spangled Banner + Ode to Joy starter content
Add two more public-domain starter songs alongside Für Elise, wired into
_BUILTIN_STARTER_SOURCES so they seed into DLC_DIR/starter/ on first run:

- The Star-Spangled Banner (lead) — John Stafford Smith; cleaned the "Unknown"
  artist / placeholder album, author "Fee[dB]ack".
- Ode to Joy (lead/rhythm/bass + drums) — Beethoven. Replaces the raw
  "Ode to Joy (VST Cover)_The Adicts.feedpak" that was committed to main but
  never added to the seed list (so it bundled 23 MB of dead weight and never
  appeared). Fixed metadata (artist Beethoven, year 1824, author "Fee[dB]ack"),
  and repointed the stem from the 22 MB editor WAV to the byte-identical-render
  full.ogg (both exactly 85.324 s), shrinking the pack 23.8 MB -> 1.7 MB.

Add guard tests asserting every _BUILTIN_STARTER_SOURCES entry has its file
committed and that a seed run lands them all — this catches exactly the
listed-but-missing (or committed-but-unlisted) mismatch that left Ode to Joy
un-seeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:53:57 +02:00
OmikronApexandGitHub 6ab1ed95c9 Add Ode to Joy (VST Cover) as new starter content 2026-07-03 22:00:51 +02:00
7c873f5cc2 feat(library): seed bundled starter content into the library on first run (#743)
Ship a public-domain Für Elise (keys) feedpak as starter content so a fresh
install isn't an empty library. server._seed_builtin_starter_content() copies
bundled packs into DLC_DIR/starter/ exactly once, guarded by a marker in
CONFIG_DIR — unlike the always-reseeding diagnostic seed, a user who deletes
the starter song does not get it back. `starter/` is deliberately outside the
diagnostics/tutorials library carve-out so the song surfaces as a normal
library entry.

Extract the shared symlink-safe, mtime-aware copy loop into
_copy_builtin_packs() and route both the diagnostic and starter seeds through
it (diagnostic behavior unchanged; existing tests green).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:35:22 +02:00
68e29a8b6e fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)
* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:30 +02:00
d2b2a7e9f7 fix(tests): re-green the JS suite — 18 stale source-shape tests + 1 real seek-reason violation (#740)
main's JS suite has been red since the recent v3-library and player
refactors landed. 17 of 18 failures were test harnesses/regexes that
went stale behind real, intentional code changes; one was a genuine
contract violation in the code.

Code fix:
- session-resume seek passed 'resume' as its _audioSeek reason; the
  documented contract (enforced by song_seek.test.js) requires
  multi-word kebab-case. Renamed to 'session-resume' — no consumer
  string-matches specific reasons, so this is rename-safe.

Test updates (each pins the CURRENT contract):
- highway_colors_facade: inject HWC_PRESETS + applyHighwayStringPreset
  (new preset feature); lock presets/applyPreset into the surface test
- loop_api: stub _updateEditRegionBtn (new edit-region UI hook)
- song_close: sandbox gets window.feedBack.playQueue; assert a real
  close abandons the queue (the new queue-aware behavior)
- v3_keep_practicing: the shelf moved from client-side /api/stats/recent
  dedupe+gating to the server-side practice-suggestions recommender —
  tests now pin that (fetch, arrangement-aware card click, Promise.all)
- v3_songs_tuning: card row variable renamed song → shown (grouped cards)
- live_guitar_tone_source: accept literal ’ where &rsquo; drifted in copy
- legacy_shim_hits: normalize CRLF before fixed-width region() slicing
  (Windows-only failure; char windows shrank by one char per line)

Suite: 987/987 locally (Windows), previously 968/987 (and 18 red on CI).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:07 +02:00
OmikronApexandGitHub b6442dda75 Merge pull request #737 from got-feedback/feat/playlist-shuffle
feat(v3): playlist shuffle toggle
2026-07-03 14:21:40 +02:00
d27cbe78ba chore: remove stale root README (#739)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:49:03 +02:00
Byron GamatosandGitHub 803bd0cdf3 Bump version to 0.3.0-alpha.1 2026-07-03 13:41:50 +02:00
9456790083 fix(release): lowercase the ghcr repo name in image tags (#738)
The repo is 'got-feedback/feedBack' (capital B) after the rename, so ${GITHUB_REPOSITORY} produced an invalid Docker tag ('repository name must be lowercase'). Use ${GITHUB_REPOSITORY,,}. nightly/rc already hardcode lowercase 'feedback'.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:16:49 +02:00
286c59707b fix(tests): isolate plugin routes modules + redact .feedpak filenames (#736)
Two pre-existing failures the segfault had been masking (the run aborted at ~25%, so they never ran until #735 let the suite complete):

1) Tuner group (~24): plugins ship a bare-named routes.py, so sys.modules['routes'] leaked between plugin test dirs (achievements ran first, tuner got its module). Each plugin conftest now pops the stale 'routes' and an autouse fixture binds sys.modules['routes'] to that plugin's module for the duration of its tests (covers runtime 'import routes' in test bodies).

2) Diagnostics group (5): _SONG_FILENAME_RE never matched the tests' .feedpak/.archive filenames — it also lacked 'feedpak' (the current primary format), a real redaction gap. Added feedpak to the regex and switched the tests off the fake .archive to the real .feedpak. Verified: full suite 2183 passed, 0 failed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:01:07 +02:00
24 changed files with 808 additions and 186 deletions
+2 -2
View File
@@ -35,9 +35,9 @@ jobs:
# stable releases (no pre-release suffix). # stable releases (no pre-release suffix).
{ {
echo "tags<<TAGS_EOF" echo "tags<<TAGS_EOF"
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}" echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ghcr.io/${GITHUB_REPOSITORY}:latest" echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
fi fi
echo "TAGS_EOF" echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
-46
View File
@@ -1,46 +0,0 @@
# fee[dB]ack
## Plugins
| Plugin | Description | Install |
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
Install any plugin by cloning it into your `plugins/` directory and restarting:
```bash
cd plugins
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
docker compose restart
```
+1 -1
View File
@@ -1 +1 @@
0.3.0 0.3.0-alpha.1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)" r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
) )
_SONG_FILENAME_RE = re.compile( _SONG_FILENAME_RE = re.compile(
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b", r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
re.IGNORECASE, re.IGNORECASE,
) )
+304 -76
View File
@@ -8,6 +8,7 @@ import logging
import math import math
import os import os
import secrets import secrets
import stat
import sys import sys
import tempfile import tempfile
import shutil import shutil
@@ -5576,13 +5577,217 @@ def _get_progression_content() -> dict:
return _progression_content return _progression_content
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
sources: list[tuple[str, str]],
label: str,
update_existing: bool = True,
) -> int:
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
bundled). A pack is copied when its destination is missing. Never deletes
user files; refuses to follow a symlinked seed directory or destination and
refuses to clobber a non-regular destination (any would let a copy escape
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
prefixes every log line.
``update_existing`` controls what happens when a *regular* destination file
already exists: when True (diagnostic seed) a bundle copy newer than the
destination refreshes it; when False (one-time starter content) an existing
file is always left as-is so the user's copy is never overwritten.
Returns the number of ``sources`` that are present at their destination
afterwards (freshly seeded, refreshed, or already current) so callers can
tell whether every pack made it. A skip (missing source, symlink/non-regular
refusal, copy error) does not count.
"""
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
# and copies would land at the link target, outside the DLC tree. The
# per-file symlink guard below cannot catch this.
if dest_dir.is_symlink():
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
return 0
dest_dir.mkdir(parents=True, exist_ok=True)
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
# dest_dir *after* the check above cannot redirect the per-file stat /
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
# os.replace accepts dir_fd on POSIX even though it isn't listed in
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
dir_fd = None
if (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in os.supports_dir_fd
and os.rename in os.supports_dir_fd
):
try:
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
except OSError as exc:
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
return 0
try:
present = 0
for dest_name, rel_source in sources:
source = root / rel_source
if not source.is_file():
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
continue
# lstat the destination without following symlinks. Pinned by dir_fd
# this resolves within the real seed dir, immune to a parent swap.
try:
if dir_fd is not None:
dstat = os.lstat(dest_name, dir_fd=dir_fd)
else:
dstat = os.lstat(dest_dir / dest_name)
dest_exists = True
dest_islink = stat.S_ISLNK(dstat.st_mode)
except FileNotFoundError:
dest_exists = False
dest_islink = False
except OSError as exc:
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
continue
# Refuse to seed through a symlink at the destination name.
if dest_islink:
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
continue
# A non-regular destination (directory, fifo, …) the user placed
# there: never clobber it, and never count it as present — otherwise
# a one-time seed would mark itself done without a real pack on disk.
if dest_exists and not stat.S_ISREG(dstat.st_mode):
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
continue
if dest_exists:
# A regular file is already there. One-time seeds (starter
# content) must never overwrite the user's copy; refreshing
# seeds (diagnostics) replace it only when the bundle is newer.
if not update_existing:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
try:
src_mtime = source.stat().st_mtime
except OSError as exc:
log.warning("%s: cannot stat source %s: %s", label, source, exc)
continue
if src_mtime <= dstat.st_mtime:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
action = "updated"
else:
action = "seeded"
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
present += 1
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
else:
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
return present
finally:
if dir_fd is not None:
os.close(dir_fd)
def _write_builtin_pack(
source: Path,
dest_dir: Path,
dest_name: str,
dir_fd: int | None,
) -> bool:
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
Writes to a temp file then ``os.replace()``s onto the final name so a
symlink raced in at the destination is overwritten (rename semantics), not
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
replace), closing the parent-directory TOCTOU; otherwise falls back to
path-based temp+replace. Returns True on success. Never raises.
"""
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
# can't permanently block later seeds via an EEXIST collision.
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
try:
src_stat = source.stat()
except OSError as exc:
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
return False
if dir_fd is not None:
tmp_fd = None
try:
tmp_fd = os.open(
tmp_name,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd,
)
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
tmp_fd = None # fdopen now owns the descriptor
shutil.copyfileobj(sf, tf)
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
# refresh check matches the shutil.copy2 fallback path. Best-effort.
try:
os.utime(
dest_name,
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
dir_fd=dir_fd,
follow_symlinks=False,
)
except OSError as exc:
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
return True
except OSError as exc:
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
if tmp_fd is not None:
try:
os.close(tmp_fd)
except OSError:
pass
try:
os.unlink(tmp_name, dir_fd=dir_fd)
except OSError:
pass
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
os.close(fd)
shutil.copy2(source, tmp)
os.replace(tmp, dest_dir / dest_name)
tmp = None
return True
except OSError as exc:
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None: def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
"""Copy bundled diagnostic sloppaks into DLC before library scan. """Copy bundled diagnostic sloppaks into DLC before library scan.
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
when the destination is missing or older than the repo/bundle source. when the destination is missing or older than the repo/bundle source.
Never deletes user files or touches manually copied paths (e.g. Never deletes user files or touches manually copied paths (e.g.
``diagnostics-test/``). Logs and continues on missing source or copy errors. ``diagnostics-test/``). Re-seeds whenever the destination is missing so the
diagnostic target is always available. Logs and continues on errors.
""" """
try: try:
if dlc is None: if dlc is None:
@@ -5590,86 +5795,108 @@ def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
if dlc is None: if dlc is None:
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping") log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
return return
_copy_builtin_packs(
root = _feedBack_server_root() _feedBack_server_root(),
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept _BUILTIN_DIAGNOSTIC_SOURCES,
# it and copies would land at the link target, outside the DLC tree. "Builtin diagnostic seed",
# The per-file is_symlink() guard below cannot catch this.
if dest_dir.is_symlink():
log.warning(
"Builtin diagnostic seed: %s is a symlink, skipping all seeding",
_BUILTIN_DIAGNOSTIC_SUBDIR,
)
return
dest_dir.mkdir(parents=True, exist_ok=True)
for dest_name, rel_source in _BUILTIN_DIAGNOSTIC_SOURCES:
source = root / rel_source
dest = dest_dir / dest_name
if not source.is_file():
log.warning(
"Builtin diagnostic seed: source missing, skipping %s (%s)",
dest_name,
source,
)
continue
# Refuse to seed through a symlink. is_file()/stat()/copy2 all
# follow links, so a symlink planted at the destination would let
# the copy redirect outside diagnostics-builtin/ and overwrite an
# arbitrary file the server user can write. Skip and warn; never
# touch the link or its target.
if dest.is_symlink():
log.warning(
"Builtin diagnostic seed: destination is a symlink, skipping %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
if dest.is_file():
try:
if source.stat().st_mtime <= dest.stat().st_mtime:
log.info(
"Builtin diagnostic seed: already present %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
action = "updated"
except OSError as exc:
log.warning(
"Builtin diagnostic seed: cannot compare %s and %s: %s",
source,
dest,
exc,
)
continue
else:
action = "seeded"
try:
shutil.copy2(source, dest)
log.info(
"Builtin diagnostic seed: %s %s -> %s/%s",
action,
source.name,
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
except OSError as exc:
log.warning(
"Builtin diagnostic seed: failed to copy %s -> %s: %s",
source,
dest,
exc,
) )
except Exception: except Exception:
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True) log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
# seeded packs surface as ordinary library songs.
_BUILTIN_STARTER_SUBDIR = "starter"
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
(
"beethoven-fur_elise.feedpak",
"content/starter/beethoven-fur_elise.feedpak",
),
(
"star_spangled_banner.feedpak",
"content/starter/star_spangled_banner.feedpak",
),
(
"beethoven-ode_to_joy.feedpak",
"content/starter/beethoven-ode_to_joy.feedpak",
),
]
_STARTER_SEED_MARKER = ".starter-content-seeded"
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
folder configured seeds the packs and writes the marker; subsequent runs are
no-ops, so a user who deletes the starter song does not get it back on the
next launch. Symlink-safe; never deletes user files. Logs, never raises.
"""
try:
marker = CONFIG_DIR / _STARTER_SEED_MARKER
# Already seeded? The marker is a sentinel: any existing path there
# (regular file, or a symlink/dir a user deliberately planted to opt
# out) means "done" — lstat so we detect it without following a symlink.
# Worst case of a planted marker is simply no starter content, never a
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
# *through* a symlink regardless.
try:
os.lstat(marker)
return
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
return
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
# No DLC yet — leave the marker unwritten so we retry once a
# library folder is configured.
log.debug("Starter content seed: no DLC folder configured, skipping")
return
present = _copy_builtin_packs(
_feedBack_server_root(),
dlc / _BUILTIN_STARTER_SUBDIR,
_BUILTIN_STARTER_SOURCES,
"Starter content seed",
update_existing=False,
)
# Only mark seeding complete once every starter pack is actually in
# place. If a source was missing or a copy failed, leave the marker
# unwritten so the next launch retries rather than permanently skipping.
if present < len(_BUILTIN_STARTER_SOURCES):
log.info(
"Starter content seed: %d/%d packs present, will retry next launch",
present,
len(_BUILTIN_STARTER_SOURCES),
)
return
# Record completion with an exclusive, no-follow create so a planted or
# raced symlink at the marker path can't redirect the write outside
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
# symlink, so we never write through one.
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(marker, flags, 0o644)
try:
os.write(fd, b"1\n")
finally:
os.close(fd)
except FileExistsError:
pass # already marked (or a non-regular path is squatting) — fine
except OSError as exc:
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
except Exception:
log.warning("Starter content seed: unexpected error", exc_info=True)
def _background_scan(): def _background_scan():
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing. """Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
@@ -5690,6 +5917,7 @@ def _background_scan():
return return
_seed_builtin_diagnostic_sloppaks(dlc) _seed_builtin_diagnostic_sloppaks(dlc)
_seed_builtin_starter_content(dlc)
# Listing can fail on macOS without Full Disk Access, or on Docker if the # Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently # path isn't shared. Report the failure explicitly rather than silently
+42 -22
View File
@@ -6195,7 +6195,7 @@ window.feedBack.on('song:ready', () => {
setSpeed(pend.speed); setSpeed(pend.speed);
} }
} catch (_) { /* speed restore is best-effort */ } } catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume')) Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); }) .then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err)); .catch((err) => console.warn('[app] resume failed:', err));
}); });
@@ -10977,20 +10977,19 @@ async function loadPlugins() {
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || '')); const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
return nameDelta || String(a.id || '').localeCompare(String(b.id || '')); return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
}); });
const livePluginIds = new Set(plugins.map((plugin) => plugin.id)); // NOTE deliberately NO stale-contribution sweep for plugins absent
for (const [pluginId, contributions] of _pluginUiContributions) { // from this response. Absent ≠ uninstalled: the backend clears its
if (livePluginIds.has(pluginId)) continue; // plugin registry at the start of load_plugins() and repopulates it
const stalePlugin = { id: pluginId }; // incrementally while HTTP stays up, so every backend restart serves a
for (const contribution of contributions) { // window of partial (even empty) responses. The old sweep unmounted UI
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution); // contributions and unregistered capability participants on mere
} // absence, permanently breaking still-loaded plugins — their scripts
try { // don't re-run (loadedScripts guard below), so nothing ever
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId); // re-registered. A genuine mid-session uninstall now leaves the
} catch (e) { // (already-evaluated, un-unloadable) script's contributions in place
console.warn(`capability participant unregister failed for ${pluginId}:`, e); // until reload; its nav entry still disappears because nav is rebuilt
} // from the response each round. Same invariant as the settings/screen
_pluginUiContributions.delete(pluginId); // DOM wipe and _reconcilePluginStyles below.
}
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins'); console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
try { try {
@@ -11132,17 +11131,23 @@ async function loadPlugins() {
loadedStyles.set(plugin.id, wantedVersion); loadedStyles.set(plugin.id, wantedVersion);
}; };
const _reconcilePluginStyles = (currentPlugins) => { const _reconcilePluginStyles = (currentPlugins) => {
// Drop stylesheets for plugins that vanished from /api/plugins or are // Drop stylesheets for plugins the response KNOWS about but that
// no longer ready+styled this round. _injectPluginStyles below only // are no longer ready+styled this round. _injectPluginStyles below
// visits plugins still returned by the API, so an uninstalled or // only visits plugins still returned by the API, so a newly-not-
// newly-not-ready plugin would otherwise keep its <link> applying. // ready or unstyled plugin would otherwise keep its <link>
// applying. Plugins merely ABSENT from the response keep their
// stylesheet — a transient partial response during a backend
// restart is not an uninstall (same invariant as the screen/
// settings wipe below), and stripping the <link> would leave a
// still-loaded plugin visible but unstyled.
const responded = new Set(currentPlugins.map((p) => p.id));
const styled = new Set( const styled = new Set(
currentPlugins currentPlugins
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles) .filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
.map((p) => p.id), .map((p) => p.id),
); );
for (const id of Array.from(loadedStyles.keys())) { for (const id of Array.from(loadedStyles.keys())) {
if (!styled.has(id)) { if (responded.has(id) && !styled.has(id)) {
_removePluginStyleTags(id); _removePluginStyleTags(id);
loadedStyles.delete(id); loadedStyles.delete(id);
} }
@@ -11155,6 +11160,18 @@ async function loadPlugins() {
if (pid) existingSettingsByPluginId.set(pid, child); if (pid) existingSettingsByPluginId.set(pid, child);
} }
} }
// Plugins named in THIS response. A plugin can be transiently absent
// from /api/plugins — the backend clears its registry at the start of
// load_plugins() and repopulates it incrementally while HTTP stays up,
// so every backend restart serves a window of partial (even empty)
// responses. The wipe loops below must never treat that absence as an
// uninstall: stripping a still-loaded plugin's DOM while keeping its
// loadedScripts entry made the NEXT refetch fail the DOM check and
// re-evaluate its screen.js mid-session — which duplicated the desktop
// audio_engine's native signal chain (its init re-ran against the
// surviving engine chain). Absent plugins keep their DOM and script;
// they're re-reconciled when they reappear in a later response.
const respondedIds = new Set(plugins.map((p) => p.id));
const alreadyHydrated = new Set(); const alreadyHydrated = new Set();
for (const p of plugins) { for (const p of plugins) {
if (!p.has_script) continue; if (!p.has_script) continue;
@@ -11182,7 +11199,10 @@ async function loadPlugins() {
for (const container of _pluginSettingsContainers()) { for (const container of _pluginSettingsContainers()) {
[...container.children].forEach((el) => { [...container.children].forEach((el) => {
const pid = el.dataset ? el.dataset.pluginId : null; const pid = el.dataset ? el.dataset.pluginId : null;
if (!pid || !alreadyHydrated.has(pid)) el.remove(); // Remove junk (no plugin id) and plugins the response KNOWS
// about but that failed hydration; leave plugins absent from
// the response untouched (see respondedIds above).
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
}); });
} }
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => { document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
@@ -11191,7 +11211,7 @@ async function loadPlugins() {
// change shipped — both forms strip a single leading "plugin-". // change shipped — both forms strip a single leading "plugin-".
const pid = (el.dataset && el.dataset.pluginId) const pid = (el.dataset && el.dataset.pluginId)
|| el.id.replace(/^plugin-/, ''); || el.id.replace(/^plugin-/, '');
if (!alreadyHydrated.has(pid)) el.remove(); if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
}); });
// Plugin settings area hosts both "Plugin Updates" and per-plugin // Plugin settings area hosts both "Plugin Updates" and per-plugin
+13 -5
View File
@@ -35,10 +35,11 @@ function buildFacade() {
'return _hwcInstallFacade;', 'return _hwcInstallFacade;',
].join('\n'); ].join('\n');
const params = [ const params = [
'window', 'HWC_SLOTS', 'console', 'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors', 'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape', '_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare', 'applyHighwayStringColors', 'applyHighwayStringPreset',
'encodeHighwayColorShare', 'decodeHighwayColorShare',
]; ];
const listeners = {}; const listeners = {};
@@ -64,14 +65,19 @@ function buildFacade() {
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass], _hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
_hwcChartShape: () => ({ sc: 6, isBass: false }), _hwcChartShape: () => ({ sc: 6, isBass: false }),
applyHighwayStringColors: (m) => { calls.push(['apply', m]); }, applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE', encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }), decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
}; };
const HWC_PRESETS = [
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
];
const installer = new Function(...params, body)( const installer = new Function(...params, body)(
win, HWC_SLOTS, console, win, HWC_SLOTS, HWC_PRESETS, console,
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors, stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape, stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare, stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
); );
installer(); installer();
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs }; return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
const { api } = buildFacade(); const { api } = buildFacade();
assert.equal(api.version, 1); assert.equal(api.version, 1);
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective', for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) { 'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`); assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
} }
assert.deepEqual(api.slots.map((s) => s.key), assert.deepEqual(api.slots.map((s) => s.key),
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order'); ['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
// One-click presets: exposed as detached [{ id, label, colors }] copies.
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
}); });
test('facade read methods delegate to the manager', () => { test('facade read methods delegate to the manager', () => {
+4 -1
View File
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js'); const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) { function source(file) {
return fs.readFileSync(file, 'utf8'); // Normalize CRLF: region() slices fixed CHARACTER windows, so on a
// Windows checkout (autocrlf) every line costs one extra char and the
// assertion target can fall outside the window.
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
} }
function region(src, needle, length = 1200) { function region(src, needle, length = 1200) {
+3 -1
View File
@@ -40,7 +40,9 @@ test('settings UI exposes tone source select with all options', () => {
assert.match(html, /value="external_hardware"/); assert.match(html, /value="external_hardware"/);
assert.match(html, /value="spark_control_x"/); assert.match(html, /value="spark_control_x"/);
assert.match(html, /Live guitar tone source/); assert.match(html, /Live guitar tone source/);
assert.match(html, /won&rsquo;t warn that no internal amp tone is loaded/); // Apostrophe form drifted from the &rsquo; entity to the literal in a
// copy pass — accept entity, typographic, or plain apostrophe.
assert.match(html, /won(?:&rsquo;||')t warn that no internal amp tone is loaded/);
}); });
test('player audio rail exposes tone source select', () => { test('player audio rail exposes tone source select', () => {
+1
View File
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
sectionPracticeModeCalls.push({ on, opts: opts || {} }); sectionPracticeModeCalls.push({ on, opts: opts || {} });
} }
function _updateSectionPracticeHighlight(ct) {} function _updateSectionPracticeHighlight(ct) {}
function _updateEditRegionBtn() {}
${extractFunction(src, 'function clearLoop(')} ${extractFunction(src, 'function clearLoop(')}
${extractFunction(src, 'function _syncSavedLoopSelection()')} ${extractFunction(src, 'function _syncSavedLoopSelection()')}
${extractFunction(src, 'async function setLoop(')} ${extractFunction(src, 'async function setLoop(')}
+115
View File
@@ -0,0 +1,115 @@
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
// merely ABSENT from the current /api/plugins response (transient partial
// response while the backend's plugin registry is repopulating after a
// restart) must keep its settings panel and screen DOM. Wiping it while its
// _loadedPluginScripts entry survives made the next refetch fail the
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
// which duplicated the desktop audio_engine's native signal chain. Plugins
// the response knows about but that failed hydration are still wiped, as is
// junk DOM carrying no plugin id.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
// nav reset that opens it to the comment introducing the next section.
function extractWipeBlock(src) {
const start = src.indexOf("navContainer.innerHTML = '';");
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
const end = src.indexOf('// Plugin settings area hosts', start);
assert.ok(end !== -1, 'wipe block end marker not found');
return src.slice(start, end);
}
function makeEl(pluginId, id) {
return {
dataset: pluginId != null ? { pluginId } : {},
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
removed: false,
remove() {
this.removed = true;
const idx = this._parent ? this._parent.indexOf(this) : -1;
if (idx >= 0) this._parent.splice(idx, 1);
},
};
}
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
const src = fs.readFileSync(APP_JS, 'utf8');
const block = extractWipeBlock(src);
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
const container = { children: settingsChildren };
const sandbox = {
navContainer: { innerHTML: 'seed' },
mobileNavContainer: { innerHTML: 'seed' },
_pluginSettingsContainers: () => [container],
respondedIds,
alreadyHydrated,
document: {
querySelectorAll: (sel) => {
assert.equal(sel, '.screen[id^="plugin-"]');
return screens.slice();
},
},
};
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
return sandbox;
}
test('plugin absent from the response keeps its settings + screen DOM', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(), // partial response: plugin missing
alreadyHydrated: new Set(), // scan loop never saw it either
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
assert.equal(screen.removed, false, 'screen must survive a partial response');
});
test('plugin present in the response but not hydrated is wiped', () => {
const settings = makeEl('stale_plugin');
const screen = makeEl('stale_plugin');
runWipe({
respondedIds: new Set(['stale_plugin']),
alreadyHydrated: new Set(),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, true);
assert.equal(screen.removed, true);
});
test('hydrated plugin present in the response is preserved', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(['audio_engine']),
alreadyHydrated: new Set(['audio_engine']),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false);
assert.equal(screen.removed, false);
});
test('junk DOM without a plugin id is still removed', () => {
const junkSettings = makeEl(null);
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
const junkScreen = makeEl(null, 'plugin-');
runWipe({
respondedIds: new Set(['whatever']),
alreadyHydrated: new Set(),
settingsChildren: [junkSettings],
screens: [junkScreen],
});
assert.equal(junkSettings.removed, true);
assert.equal(junkScreen.removed, true);
});
+9 -4
View File
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']); assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
}); });
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => { test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
const { inject, reconcile, headLinks } = setupSandbox(); const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' })); inject(plug({ id: 'a' }));
inject(plug({ id: 'b' })); inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2); assert.equal(headLinks.length, 2);
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped. // `a` is missing from this response. That happens transiently during a
// backend restart (the plugin registry repopulates while HTTP stays up),
// so absence is NOT an uninstall signal — the still-loaded plugin must
// keep its stylesheet or it renders visible-but-unstyled until it
// reappears. Explicit removal still happens via the not-ready/unstyled
// paths (tests below).
reconcile([plug({ id: 'b' })]); reconcile([plug({ id: 'b' })]);
assert.equal(headLinks.length, 1); assert.equal(headLinks.length, 2);
assert.equal(headLinks[0].dataset.pluginId, 'b'); assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
}); });
test('reconcile removes the <link> of a plugin that is no longer ready', () => { test('reconcile removes the <link> of a plugin that is no longer ready', () => {
+4
View File
@@ -42,7 +42,10 @@ function loadClose(sandbox, src) {
globalThis.__seekCalls = 0; globalThis.__seekCalls = 0;
globalThis.__playSongCalls = 0; globalThis.__playSongCalls = 0;
globalThis.__clearLoopCalls = 0; globalThis.__clearLoopCalls = 0;
globalThis.__queueClearCalls = 0;
globalThis.__audioCurrentTimeSets = []; globalThis.__audioCurrentTimeSets = [];
// closeCurrentSong abandons any play-queue before leaving the player.
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
var audio = { var audio = {
_t: 42, _t: 42,
get currentTime() { return this._t; }, get currentTime() { return this._t; },
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
await sandbox.__closeCurrentSong(); await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1); assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'favorites'); assert.equal(sandbox.__showScreenCalls[0], 'favorites');
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
assert.equal(sandbox.__restartCalls, 0); assert.equal(sandbox.__restartCalls, 0);
assert.equal(sandbox.__seekCalls, 0); assert.equal(sandbox.__seekCalls, 0);
assert.equal(sandbox.__playSongCalls, 0); assert.equal(sandbox.__playSongCalls, 0);
+11 -9
View File
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
); );
}); });
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => { test('the shelf is the server-side practice-suggestions recommender', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/); // The old client-side pipeline (fetch /api/stats/recent, dedupe by
// Mastery is gated on the per-SONG best (state.accuracy, what the badge // filename, gate on state.accuracy) moved server-side: the growth-edge
// shows), not the per-arrangement recents row, and each filename appears // recommender gates (not-mastered) + aggregates per song and picks the
// once — so no green-badged "keep practicing" card and no duplicates. // arrangement closest to mastery. The client renders its rows as-is.
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
// A shelf card click opens the row's recommended arrangement, not the
// song's default.
assert.match( assert.match(
src, src,
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/, /data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY', 'shelf cards must pass the recommended arrangement to playSong',
); );
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
}); });
test('the meter + shelf fetch together and a stale render is discarded', () => { test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/, assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
'the two reads must be issued together (Promise.all), not sequentially'); 'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/, assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token'); 'a stale render must be superseded by a newer one via a token');
+3 -1
View File
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
test('v3 songs.js uses display helpers for album-art tuning badge', () => { test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8'); const src = fs.readFileSync(SONGS_JS, 'utf8');
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/); // The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
assert.match(src, /displayTuningTargets/); assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/); assert.match(src, /parseRawTuningOffsets/);
}); });
+16
View File
@@ -7,6 +7,8 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
sys.modules.pop('routes', None)
import routes as ach_routes import routes as ach_routes
@@ -26,3 +28,17 @@ def client(tmp_path):
app = FastAPI() app = FastAPI()
ach_routes.setup(app, {"config_dir": str(tmp_path)}) ach_routes.setup(app, {"config_dir": str(tmp_path)})
return TestClient(app) return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_ach_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
prev = sys.modules.get('routes')
sys.modules['routes'] = ach_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+18
View File
@@ -5,6 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
sys.modules.pop('routes', None)
import routes as tuner_routes import routes as tuner_routes
@@ -22,3 +24,19 @@ def client(config_dir):
"unregister_tuning_provider": lambda pid: None, "unregister_tuning_provider": lambda pid: None,
}) })
return TestClient(app) return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_tuner_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
tests, so a runtime `import routes` in a test body resolves correctly
regardless of which other plugin's bare-named routes ran first."""
prev = sys.modules.get('routes')
sys.modules['routes'] = tuner_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+234
View File
@@ -0,0 +1,234 @@
"""Tests for one-time builtin starter-content seeding into DLC."""
from __future__ import annotations
import importlib
import sys
import pytest
@pytest.fixture()
def server_mod(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
(tmp_path / "config").mkdir()
monkeypatch.delenv("DLC_DIR", raising=False)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
def _source(server_mod):
return (
server_mod._feedBack_server_root()
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
)
def _dest(server_mod, dlc):
return (
dlc
/ server_mod._BUILTIN_STARTER_SUBDIR
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
)
def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
"""First run copies the bundled feedpak into starter/ and writes the marker."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_preserves_source_mtime(tmp_path, server_mod):
"""The seeded pack keeps the bundle's mtime so the diagnostic refresh check
(source newer than dest -> update) stays correct across both write paths."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
def test_starter_is_not_carved_out_of_the_library():
"""`starter/` must NOT collide with the diagnostics/tutorials carve-out —
otherwise seeded songs would never appear in the library listing."""
assert "starter" not in {"diagnostics-builtin", "tutorials-builtin"}
def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
"""After the first seed, deleting the song does NOT bring it back: the
marker makes starter seeding a one-time welcome."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
# User removes the starter song.
dest.unlink()
# A subsequent launch must not re-seed it.
server_mod._seed_builtin_starter_content(dlc)
assert not dest.exists()
def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
"""With no DLC folder, seeding is skipped WITHOUT writing the marker, so it
retries once a library folder exists."""
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
server_mod._seed_builtin_starter_content(None)
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
# Now a DLC is configured: the deferred seed runs.
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).is_file()
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
"""A symlinked starter/ dir is refused so copies can't escape the DLC tree."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
server_mod._seed_builtin_starter_content(dlc)
assert list(outside_dir.iterdir()) == []
# An incomplete seed must NOT write the marker, so a later launch retries.
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
"""One-time starter seeding must never replace a user's own file at the
destination, even if the bundled pack has a newer mtime."""
import os as _os
dlc = tmp_path / "dlc"
dlc.mkdir()
dest = _dest(server_mod, dlc)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"user's own edited pack")
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
server_mod._seed_builtin_starter_content(dlc)
assert dest.read_bytes() == b"user's own edited pack" # untouched
# counted as already-present, so the one-time seed considers itself done
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
"""A directory sitting at the destination name is neither clobbered nor
counted as present, so the marker stays unwritten and seeding retries."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
bogus = _dest(server_mod, dlc)
bogus.parent.mkdir(parents=True, exist_ok=True)
bogus.mkdir() # user (or junk) placed a directory where the pack goes
server_mod._seed_builtin_starter_content(dlc)
assert bogus.is_dir() # untouched
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
"""If a starter source can't be found, the marker stays unwritten and the
seed is retried on the next launch (rather than permanently skipped)."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_STARTER_SOURCES",
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
)
server_mod._seed_builtin_starter_content(dlc)
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_every_starter_source_file_is_present(server_mod):
"""Every entry in _BUILTIN_STARTER_SOURCES must have its bundled file on
disk — otherwise the all-present gate never fires and NOTHING seeds (a
listed-but-missing pack silently disables starter seeding entirely). In CI
the checkout is clean, so "on disk" == committed."""
root = server_mod._feedBack_server_root()
missing = [
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
if not (root / rel).is_file()
]
assert not missing, f"listed starter sources missing on disk: {missing}"
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
"""A real seed run copies every listed pack into starter/ and marks done."""
root = server_mod._feedBack_server_root()
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
if not (root / rel).is_file():
pytest.skip(f"starter source not present in checkout: {rel}")
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
assert dest.is_file(), f"pack not seeded: {dest_name}"
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_no_unlisted_starter_pack_on_disk(server_mod):
"""The inverse guard: every content/starter/*.feedpak on disk must be wired
into _BUILTIN_STARTER_SOURCES. An unlisted pack bundles into builds as dead
weight and never seeds — exactly how the raw Ode-to-Joy pack slipped onto
main before being wired up. In CI the checkout is clean, so this flags any
stray/committed pack that isn't listed."""
root = server_mod._feedBack_server_root()
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
if not listed:
pytest.skip("no starter sources declared")
content_dir = (root / next(iter(listed))).parent # all sources share this dir
if not content_dir.is_dir():
pytest.skip(f"starter content dir absent: {content_dir}")
on_disk = {p.relative_to(root).as_posix() for p in content_dir.glob("*.feedpak")}
unlisted = on_disk - listed
assert not unlisted, (
"committed but not in _BUILTIN_STARTER_SOURCES (would bundle as dead "
f"weight and never seed): {sorted(unlisted)}"
)
+4 -4
View File
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
kw["client_contributions"] = { kw["client_contributions"] = {
"note_detect": { "note_detect": {
"schema": "feedBack.audio_session.diagnostics.v1", "schema": "feedBack.audio_session.diagnostics.v1",
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")}, "session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}}, "domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
} }
} }
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
kw = _basic_kwargs(tmp_path) kw = _basic_kwargs(tmp_path)
kw["include"]["console"] = True kw["include"]["console"] = True
kw["redact"] = True kw["redact"] = True
secret_path = "/home/alice/Music/DLC/my_song.archive" secret_path = "/home/alice/Music/DLC/my_song.feedpak"
kw["client_console"] = [ kw["client_console"] = [
{ {
"level": "error", "level": "error",
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
kw["include"]["console"] = True kw["include"]["console"] = True
kw["redact"] = True kw["redact"] = True
kw["client_console"] = [ kw["client_console"] = [
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]}, {"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak ok"]},
] ]
zip_bytes, _name, _m = db.build_bundle(**kw) zip_bytes, _name, _m = db.build_bundle(**kw)
with _open_zip(zip_bytes) as zf: with _open_zip(zip_bytes) as zf:
console = json.loads(zf.read("client/console.json")) console = json.loads(zf.read("client/console.json"))
# The song filename should be replaced with a hash token, not appear verbatim. # The song filename should be replaced with a hash token, not appear verbatim.
assert "my_song.archive" not in console["entries"][0]["args"][0] assert "my_song.feedpak" not in console["entries"][0]["args"][0]
def test_console_non_string_non_dict_args_pass_through(tmp_path): def test_console_non_string_non_dict_args_pass_through(tmp_path):
+5 -5
View File
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
def test_dlc_path_replaced(): def test_dlc_path_replaced():
r = Redactor(dlc_dir=Path("/dlc/songs")) r = Redactor(dlc_dir=Path("/dlc/songs"))
out = r.redact_text("loaded from /dlc/songs/foo.archive") out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
assert "<DLC_DIR>" in out assert "<DLC_DIR>" in out
assert "/dlc/songs" not in out assert "/dlc/songs" not in out
assert r.counts["paths_replaced"] == 1 assert r.counts["paths_replaced"] == 1
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
def test_song_filename_redacted_consistently(): def test_song_filename_redacted_consistently():
r = Redactor() r = Redactor()
a = r.redact_text("Loading Test-Artist_Test-Song.archive") a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again") b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak again")
token_a = a.split("Loading ")[1].strip() token_a = a.split("Loading ")[1].strip()
token_b = b.split("Replaying ")[1].split(" ")[0] token_b = b.split("Replaying ")[1].split(" ")[0]
assert token_a == token_b assert token_a == token_b
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
def test_different_redactors_produce_different_tokens(): def test_different_redactors_produce_different_tokens():
a = Redactor() a = Redactor()
b = Redactor() b = Redactor()
out_a = a.redact_text("Foo.archive") out_a = a.redact_text("Foo.feedpak")
out_b = b.redact_text("Foo.archive") out_b = b.redact_text("Foo.feedpak")
assert out_a != out_b assert out_a != out_b
+17 -7
View File
@@ -38,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
def test_plugin_loader_unmounts_contributions_for_removed_plugins(): def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
# A plugin transiently absent from /api/plugins (the backend clears its
# registry at the start of load_plugins() and repopulates incrementally
# while HTTP stays up, so restarts serve partial responses) must NOT be
# torn down: the old absence sweep unmounted UI contributions and
# unregistered the capability participant with no re-registration path
# (plugin scripts don't re-run), and the DOM/style wipes forced a
# mid-session screen.js re-evaluation that duplicated the desktop
# audio_engine's native signal chain.
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8") source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source # The absence-triggered sweep is gone (rationale comment in its place)...
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source assert "const livePluginIds" not in source
assert "const stalePlugin = { id: pluginId }" in source assert "const stalePlugin = { id: pluginId }" not in source
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source assert "deliberately NO stale-contribution sweep" in source
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source # ...and the DOM/style reconcilers only act on plugins the response names.
assert "_pluginUiContributions.delete(pluginId)" in source assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
assert "responded.has(id) && !styled.has(id)" in source