Files
teamspeak-music-bot/scripts/test_bot_remove.py
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

100 lines
3.2 KiB
Python

"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row.
Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and
asserts the row disappears without any page reload. Does not touch any
existing user bot.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
EPHEMERAL_NAME = "rmbot_test"
EPHEMERAL_NICK = "RmBotTest"
def api(path, method="GET", **kw):
r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw)
r.raise_for_status()
return r.json() if r.text else None
def cleanup():
for b in api("/api/bot/")["bots"]:
if b["name"] == EPHEMERAL_NAME:
try:
api(f"/api/bot/{b['id']}", method="DELETE")
except Exception:
pass
def main():
cleanup()
new = api(
"/api/bot/",
method="POST",
json={
"name": EPHEMERAL_NAME,
"serverAddress": "127.0.0.1",
"serverPort": 9987,
"nickname": EPHEMERAL_NICK,
"autoStart": False,
},
)
bot_id = new["id"]
print(f"[setup] created ephemeral bot {bot_id[:8]}")
try:
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.8)
# Open dropdown and confirm the new bot row is present
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-dropdown")
rows_before = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows before remove: {rows_before}")
# Match the ephemeral row by its name text
present = (
page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count()
)
assert present == 1, f"ephemeral row not found (got {present})"
# Delete via API
api(f"/api/bot/{bot_id}", method="DELETE")
print("[api] deleted bot")
# Wait up to 4s for UI to drop the row
removed = False
for _ in range(40):
if (
page.locator(
".bot-dropdown-row", has_text=EPHEMERAL_NAME
).count()
== 0
):
removed = True
break
time.sleep(0.1)
rows_after = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows after remove: {rows_after}")
assert removed, "ephemeral row did not disappear from UI after DELETE"
assert rows_after == rows_before - 1, (
f"row count mismatch: before={rows_before} after={rows_after}"
)
print("[PASS] bot removal propagates to UI via WS")
finally:
browser.close()
finally:
cleanup()
if __name__ == "__main__":
main()