Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 1c88354165 feat(library): seed bundled starter content into the library on first run
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:28:08 +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
22 changed files with 749 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.
+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,
) )
+297 -77
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,100 @@ 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",
),
]
_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 +5909,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)
+183
View File
@@ -0,0 +1,183 @@
"""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()
+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