mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 20:58:31 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c4f16412 | ||
|
|
6ab1ed95c9 | ||
|
|
7c873f5cc2 | ||
|
|
68e29a8b6e | ||
|
|
d2b2a7e9f7 | ||
|
|
b6442dda75 | ||
|
|
d27cbe78ba | ||
|
|
803bd0cdf3 | ||
|
|
9456790083 | ||
|
|
286c59707b |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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’t warn that no internal amp tone is loaded/);
|
// Apostrophe form drifted from the ’ entity to the literal ’ in a
|
||||||
|
// copy pass — accept entity, typographic, or plain apostrophe.
|
||||||
|
assert.match(html, /won(?:’|’|')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', () => {
|
||||||
|
|||||||
@@ -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(')}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user