Files
saopig1andClaude Opus 4.6 4643f70f4a fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional
Major bug fixes and corner-case hardening across the backend, plus a
comprehensive feature test suite. All 94 unit tests + 51 integration
tests pass against a local TS3 server.

Lifecycle & state consistency
-----------------------------
- Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS
  handshake no longer blocks the /start HTTP call forever; the failing
  instance is torn down and the caller gets a clean 500.
- Bug B: executeCommand rejects audio-dispatching commands (play, add,
  next, skip, prev, playlist, album, fm) when the bot is disconnected.
  Config-only commands (vol, mode, clear, stop, queue, now, lyrics)
  still work so the UI stays usable while offline.
- Bug C: the tsClient 'disconnected' handler always clears player state
  now, even when connect() never completed. A separate disconnectEmitted
  flag guards duplicate external event emission. Previously an orphaned
  connect attempt that idle-timed-out would leave playing=true forever.
- resolveAndPlay re-checks this.connected AFTER the URL-resolve await so
  a stop() during the network call can't spawn ffmpeg on a disconnected
  bot.
- connect() throws if disconnect() fired during the handshake await,
  preventing a concurrent stop from being overwritten by a late connected
  flag flip.
- startBot always disconnects the outgoing BotInstance before creating
  a replacement, covering the mid-handshake case where isConnected()
  still returned false but the library client was live.
- startBot now reuses the stored identity so server groups granted to
  the bot survive restarts (was regenerating a fresh UID each time).

WebSocket reliability
---------------------
- BotManager extends EventEmitter and emits 'botInstance' whenever a
  new instance is created. websocket.ts listens and re-attaches its
  stateChange / connected / disconnected listeners immediately, fixing
  the bug where player-bar UI never updated until manual refresh.
- attachedBots map now stores the BotInstance reference and detaches
  stale listeners when the instance is replaced. Safety-net interval
  (5s) also reconciles to catch anything missed.
- removeBot emits 'botInstanceRemoved' -> WS broadcasts a new
  {type:"botRemoved", botId} message. Client drops the bot from its
  local store instead of showing it as permanently offline.

HTTP input validation
---------------------
- /volume rejects non-number, NaN, Infinity, and out-of-range values
  with a proper 400 instead of a 200 OK wrapping a usage-text string.
- /mode rejects anything not in {seq, loop, random, rloop} with 400.
- /seek rejects NaN / Infinity / negative (previously NaN slipped
  through typeof==="number" and poisoned seekOffset).
- /play-at validates index < queue.size() BEFORE stopping current
  playback (was silently killing the current song on invalid input).
- /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist
  all honour platform=youtube now (previously fell through to netease
  and silently played the wrong platform).

YouTube made truly optional
---------------------------
- Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only
  positive results so users can install yt-dlp mid-run and have it
  picked up without a restart.
- getAuthStatus() returns loggedIn=false with nickname
  "YouTube (yt-dlp not installed)" when the binary is missing. UI can
  grey out YouTube instead of silently returning empty searches.
- findYtDlp() picks .exe on win32 and bare binary elsewhere.
- /auth/status?platform=youtube now routes to the YouTube provider
  instead of falling through to NetEase and leaking the NetEase
  user's nickname + avatar.
- /auth/cookie rejects platform=youtube with 400 instead of clobbering
  the NetEase cookie entry.
- README documents yt-dlp install paths (bin/ local vs PATH) and adds
  a dedicated "Optional: YouTube source" section.

Bot Selector UI
---------------
- New power button in each row of the dropdown with play-state-aware
  styling: disabled + wait-cursor during API call, green highlight when
  connected, greys out when the bot is offline.
- Dropdown always visible when >=1 bot exists, bigger font + padding.

Queue correctness
-----------------
- PlayQueue.remove(current) now decrements currentIndex so next() in
  sequential mode advances to the shifted song. Previously removing
  the currently-playing track silently skipped the next track because
  current() falsely reported it as active and next() then incremented
  past it.

Vote-skip hardening
-------------------
- cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single
  voter in an empty channel can't unanimously pass a vote with
  needed=0.
- resolveAndPlay clears voteSkipUsers on every new track load so votes
  can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths.

cmdAdd parity
-------------
- cmdAdd auto-plays the newly-added song if the player was idle,
  matching /api/player/:id/add-by-id behaviour. Previously add'ing to
  an empty queue on a connected+idle bot silently enqueued without
  starting playback.

Test suite
----------
- scripts/test_full_feature.py — 51 tests across 10 groups exercising
  every HTTP endpoint, WebSocket broadcasts, all music providers, bot
  lifecycle, disconnected-state corners, seek validation, input
  validation, and the main race conditions. Captures and restores the
  target bot's initial state. Resilient to TS3 anti-flood via retry
  with exponential backoff. Runs against a real local TS3 server.
- scripts/test_rapid_cycle.py — Bugs A/B/C regressions
- scripts/test_corner_cases.py — disconnect-during-connect race, config
  commands while disconnected, etc.
- scripts/test_more_corners.py — resolveAndPlay race, seek NaN
- scripts/test_power_button.py — E2E for the new power button
- scripts/test_bot_remove.py — E2E for WS botRemoved broadcast
- scripts/test_playbar.py — player bar auto-show regression (updated
  to restore bot state on exit)
- scripts/test_multibot.py — two-bot concurrent playback monitor
- src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:35:14 +08:00

101 lines
3.6 KiB
Python

"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state.
Captures the target bot's initial connected state and restores it on exit
(including on assertion failure), so running this test never pollutes the
user's current bot setup.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
def get_bot(bot_id):
return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.2)
return False
def set_connected(bot_id, want: bool) -> None:
"""Force the bot into the given connected state via API."""
current = get_bot(bot_id)["connected"]
if current == want:
return
endpoint = "start" if want else "stop"
requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}")
wait_for_connected(bot_id, want)
def main():
bots = requests.get(f"{BASE}/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots registered, nothing to test")
return
target = bots[0]
bot_id = target["id"]
initial_connected = target["connected"]
print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}")
try:
# Force bot disconnected before the test
set_connected(bot_id, False)
assert not get_bot(bot_id)["connected"], "bot should be disconnected at start"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.6)
# Open dropdown
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-power-btn")
# Click the power button to start
page.locator(".bot-power-btn").first.click()
print("[ui] clicked power (start)")
assert wait_for_connected(bot_id, True), "bot should be connected after clicking start"
print("[api] bot connected = True")
# Let UI catch up via WS then re-open the dropdown to re-check class
time.sleep(1.0)
page.locator(".bot-selector-btn").click() # close
time.sleep(0.2)
page.locator(".bot-selector-btn").click() # reopen
page.wait_for_selector(".bot-power-btn.online", timeout=3000)
print("[ui] power button now shows .online class")
# Click again to stop
page.locator(".bot-power-btn.online").first.click()
print("[ui] clicked power (stop)")
assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop"
print("[api] bot connected = False")
print("[PASS] power button toggles bot connection")
finally:
browser.close()
finally:
# Always restore the initial state so the test never leaves the bot
# in an unexpected place
set_connected(bot_id, initial_connected)
final = get_bot(bot_id)["connected"]
print(f"[restore] bot connected={final} (initial was {initial_connected})")
if final != initial_connected:
print("[warn] failed to restore initial connected state")
if __name__ == "__main__":
main()