mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
This commit is contained in:
1 parent
6e828b9c2d
commit
4643f70f4a
21 files changed
+2305
-134
No files matched your search
@@ -0,0 +1,99 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Corner case regressions that go beyond Bugs A/B/C.
|
||||
|
||||
A. Race — disconnect() called during connect()'s awaited handshake must
|
||||
NOT leave the bot reporting connected=true afterwards.
|
||||
|
||||
B. Config-only commands (vol, mode, clear) must work even when the bot is
|
||||
disconnected (UI should stay usable while the bot is offline).
|
||||
|
||||
C. After a disconnect mid-playback, the player must not be able to
|
||||
auto-advance to the next queued song (trackEnd → resolveAndPlay).
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def test_config_commands_when_disconnected(bot_id):
|
||||
"""B. vol/mode/clear should succeed while bot is disconnected."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
|
||||
# volume is the simplest config-only command
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json={"volume": 60},
|
||||
)
|
||||
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json={"mode": "seq"},
|
||||
)
|
||||
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
print("[PASS] config commands (vol/mode/clear) work when disconnected")
|
||||
|
||||
|
||||
def test_play_rejected_when_disconnected(bot_id):
|
||||
"""B (negative). play/add/next/prev should still be rejected."""
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, f"play should fail while disconnected"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json={"query": "test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, f"add should fail while disconnected"
|
||||
|
||||
r = api(f"/api/player/{bot_id}/next", method="POST")
|
||||
assert r.status_code >= 400, f"next should fail while disconnected"
|
||||
|
||||
print("[PASS] audio commands (play/add/next) rejected when disconnected")
|
||||
|
||||
|
||||
def test_disconnect_during_connect_race(bot_id):
|
||||
"""A. disconnect() called while connect() is awaiting must win the race.
|
||||
|
||||
Fires a stop 200ms into a start call; after things settle the bot's
|
||||
connected state must be stable (either cleanly disconnected, or cleanly
|
||||
connected if the stop happened after connect completed). It must NOT
|
||||
end up in a weird state where connected=true but a subsequent query
|
||||
shows inconsistent data.
|
||||
"""
|
||||
# Ensure disconnected first
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1) # give TS server a moment to forget us
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
try:
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
except Exception as e:
|
||||
r = None
|
||||
print(f"[info] start threw: {e}")
|
||||
|
||||
# Wait for all state transitions to settle
|
||||
time.sleep(2)
|
||||
|
||||
b = get_bot(bot_id)
|
||||
# The key invariant: if connected is false, playing must also be false;
|
||||
# if connected is true, the transport is actually up (we can issue
|
||||
# another command without error).
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: connected={b['connected']} playing={b['playing']}"
|
||||
)
|
||||
print(
|
||||
f"[PASS] disconnect-during-connect race — final state consistent "
|
||||
f"(connected={b['connected']} playing={b['playing']})"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_guard(bot_id):
|
||||
"""C. resolveAndPlay on a disconnected bot is a no-op.
|
||||
|
||||
We can't directly invoke resolveAndPlay from the API, but we can
|
||||
verify by checking that after a stop, the bot stays idle even if we
|
||||
wait for a trackEnd-like event to fire.
|
||||
"""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1.5) # more than a frame cycle
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], (
|
||||
f"player should stay stopped after disconnect: {b}"
|
||||
)
|
||||
print("[PASS] player stays idle after disconnect (no ghost autoplay)")
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
|
||||
|
||||
try:
|
||||
test_config_commands_when_disconnected(bot_id)
|
||||
test_play_rejected_when_disconnected(bot_id)
|
||||
test_resolve_guard(bot_id)
|
||||
test_disconnect_during_connect_race(bot_id)
|
||||
print("ALL GREEN")
|
||||
finally:
|
||||
if initial:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] connected={get_bot(bot_id)['connected']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,852 @@
|
||||
"""Comprehensive feature + corner-case test for TSMusicBot against a
|
||||
local TeamSpeak 3 server.
|
||||
|
||||
Exercises every major HTTP endpoint, the WebSocket state-broadcast path,
|
||||
all music providers, bot lifecycle transitions, and a handful of races
|
||||
that have burned us in the past. Designed to be safe to run against a
|
||||
real installation: captures the target bot's initial connected/volume/
|
||||
mode settings and restores them in `finally`.
|
||||
|
||||
Usage:
|
||||
"C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py
|
||||
|
||||
Exit code: 0 if every non-skipped test passed, 1 otherwise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
POLL_INTERVAL = 0.15
|
||||
CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace
|
||||
ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy
|
||||
|
||||
|
||||
# ----------------------------- HTTP helpers ---------------------------------
|
||||
|
||||
|
||||
def api(path: str, method: str = "GET", json_body: Any = None):
|
||||
"""Return (status_code, body). Never raises."""
|
||||
try:
|
||||
fn = getattr(requests, method.lower())
|
||||
r = fn(f"{BASE}{path}", json=json_body, timeout=30)
|
||||
try:
|
||||
return r.status_code, r.json()
|
||||
except Exception:
|
||||
return r.status_code, r.text
|
||||
except Exception as e:
|
||||
return None, f"<{type(e).__name__}: {e}>"
|
||||
|
||||
|
||||
def get_bot(bot_id: str) -> dict | None:
|
||||
_, data = api("/api/bot/")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return next((b for b in data.get("bots", []) if b["id"] == bot_id), None)
|
||||
|
||||
|
||||
def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool:
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
b = get_bot(bot_id)
|
||||
if b is not None and b["connected"] is want:
|
||||
return True
|
||||
time.sleep(POLL_INTERVAL)
|
||||
return False
|
||||
|
||||
|
||||
def start_and_wait(bot_id: str, retries: int = 2) -> bool:
|
||||
"""Start the bot, tolerating transient TS3 anti-flood by retrying with
|
||||
exponential backoff. Returns True only when the bot reports connected."""
|
||||
for attempt in range(retries + 1):
|
||||
s, _ = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
if s == 200 and wait_connected(bot_id, True):
|
||||
return True
|
||||
# If /start returned an error (e.g. connect timeout from our 15s
|
||||
# deadline), back off and retry — TS3 server-side anti-flood
|
||||
# usually clears in a few seconds.
|
||||
if attempt < retries:
|
||||
time.sleep(3.0 * (attempt + 1))
|
||||
# Make sure we're fully stopped before the next attempt so
|
||||
# oldBot.disconnect() doesn't double-fire
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False, timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
def stop_and_wait(bot_id: str) -> bool:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
return wait_connected(bot_id, False)
|
||||
|
||||
|
||||
def assert_started(bot_id: str):
|
||||
"""Helper that raises with a clear message when start fails so test
|
||||
output points at 'could not connect' rather than an empty assertion."""
|
||||
if not start_and_wait(bot_id):
|
||||
raise AssertionError(
|
||||
"could not bring bot online (TS3 server may be anti-flooding "
|
||||
"or unreachable)"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------- test runner ----------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
status: str # PASS / FAIL / ERROR / SKIP
|
||||
detail: str = ""
|
||||
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
results: list[TestResult] = []
|
||||
|
||||
|
||||
def run(name: str, fn):
|
||||
global passed, failed
|
||||
try:
|
||||
fn()
|
||||
print(f" [PASS] {name}")
|
||||
passed += 1
|
||||
results.append(TestResult(name, "PASS"))
|
||||
except AssertionError as e:
|
||||
print(f" [FAIL] {name}: {e}")
|
||||
failed += 1
|
||||
results.append(TestResult(name, "FAIL", str(e)))
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {name}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}"))
|
||||
|
||||
|
||||
def skip(name: str, reason: str):
|
||||
global skipped
|
||||
print(f" [SKIP] {name} ({reason})")
|
||||
skipped += 1
|
||||
results.append(TestResult(name, "SKIP", reason))
|
||||
|
||||
|
||||
# ----------------------------- test groups ----------------------------------
|
||||
|
||||
|
||||
def group_infrastructure(bot_id: str):
|
||||
print("\n== infrastructure ==")
|
||||
|
||||
def t_health():
|
||||
s, d = api("/api/health")
|
||||
assert s == 200, f"health returned {s}"
|
||||
assert d.get("status") == "ok"
|
||||
run("GET /api/health", t_health)
|
||||
|
||||
def t_list_bots():
|
||||
s, d = api("/api/bot/")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("bots"), list)
|
||||
assert any(b["id"] == bot_id for b in d["bots"])
|
||||
run("GET /api/bot/ lists target bot", t_list_bots)
|
||||
|
||||
def t_bot_config():
|
||||
s, d = api(f"/api/bot/{bot_id}/config")
|
||||
assert s == 200
|
||||
assert d["id"] == bot_id
|
||||
assert "identity" in d
|
||||
assert "serverAddress" in d and "nickname" in d
|
||||
assert "serverPassword" in d, "serverPassword field missing"
|
||||
run("GET /api/bot/:id/config returns all fields", t_bot_config)
|
||||
|
||||
def t_404_on_unknown_bot():
|
||||
s, _ = api("/api/bot/does-not-exist/config")
|
||||
assert s == 404
|
||||
run("404 on unknown bot id", t_404_on_unknown_bot)
|
||||
|
||||
def t_quality_shape():
|
||||
s, d = api("/api/music/quality")
|
||||
assert s == 200
|
||||
for p in ("netease", "qq", "bilibili"):
|
||||
assert p in d, f"{p} missing from quality response"
|
||||
run("GET /api/music/quality shape", t_quality_shape)
|
||||
|
||||
|
||||
def group_auth_status():
|
||||
print("\n== auth status per platform ==")
|
||||
|
||||
def t_netease_ok():
|
||||
s, d = api("/api/auth/status?platform=netease")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "netease"
|
||||
assert "loggedIn" in d
|
||||
run("auth status netease", t_netease_ok)
|
||||
|
||||
def t_qq_ok():
|
||||
s, d = api("/api/auth/status?platform=qq")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "qq"
|
||||
run("auth status qq", t_qq_ok)
|
||||
|
||||
def t_bilibili_ok():
|
||||
s, d = api("/api/auth/status?platform=bilibili")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "bilibili"
|
||||
run("auth status bilibili", t_bilibili_ok)
|
||||
|
||||
def t_youtube_routed():
|
||||
# Regression: /auth/status?platform=youtube used to fall through
|
||||
# to NetEase and leak the NetEase user's nickname/avatar.
|
||||
s, d = api("/api/auth/status?platform=youtube")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "youtube", (
|
||||
f"youtube auth status leaked to {d.get('platform')}"
|
||||
)
|
||||
run("auth status youtube routes correctly", t_youtube_routed)
|
||||
|
||||
def t_youtube_cookie_rejected():
|
||||
s, d = api(
|
||||
"/api/auth/cookie",
|
||||
method="POST",
|
||||
json_body={"platform": "youtube", "cookie": "fake"},
|
||||
)
|
||||
assert s == 400, f"youtube cookie should be rejected, got {s}: {d}"
|
||||
run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected)
|
||||
|
||||
|
||||
def group_search():
|
||||
print("\n== multi-platform search ==")
|
||||
|
||||
def search(platform: str, query: str = "test"):
|
||||
return api(f"/api/music/search?q={query}&platform={platform}&limit=1")
|
||||
|
||||
def t_netease():
|
||||
s, d = search("netease")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("netease search", t_netease)
|
||||
|
||||
def t_qq():
|
||||
s, d = search("qq")
|
||||
assert s == 200
|
||||
# QQ may return 0 results if no cookie, but shouldn't error
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("qq search (empty ok)", t_qq)
|
||||
|
||||
def t_bilibili():
|
||||
s, d = search("bilibili")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("bilibili search", t_bilibili)
|
||||
|
||||
def t_missing_query():
|
||||
s, _ = api("/api/music/search?platform=netease")
|
||||
assert s == 400, "missing q should 400"
|
||||
run("400 on missing query", t_missing_query)
|
||||
|
||||
_, auth = api("/api/auth/status?platform=youtube")
|
||||
youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True
|
||||
|
||||
if youtube_available:
|
||||
def t_youtube():
|
||||
s, d = search("youtube", "lofi")
|
||||
assert s == 200
|
||||
songs = d.get("songs", [])
|
||||
assert len(songs) >= 1, "expected at least 1 YouTube result"
|
||||
assert songs[0]["platform"] == "youtube"
|
||||
run("youtube search (yt-dlp installed)", t_youtube)
|
||||
else:
|
||||
skip("youtube search", "yt-dlp not installed")
|
||||
|
||||
|
||||
def group_lifecycle(bot_id: str):
|
||||
print("\n== connection lifecycle ==")
|
||||
|
||||
def t_stop_from_any_state():
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
assert wait_connected(bot_id, False), "bot did not stop"
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], "playing should be false after stop"
|
||||
run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state)
|
||||
|
||||
def t_start_completes_quickly():
|
||||
t0 = time.time()
|
||||
s, d = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
elapsed = time.time() - t0
|
||||
assert s == 200, f"start failed: {d}"
|
||||
assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)"
|
||||
assert wait_connected(bot_id, True)
|
||||
run("start completes well under 15s deadline", t_start_completes_quickly)
|
||||
|
||||
def t_identity_persists():
|
||||
assert_started(bot_id)
|
||||
_, cfg1 = api(f"/api/bot/{bot_id}/config")
|
||||
id1 = cfg1["identity"]
|
||||
assert id1, "identity empty after first start"
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
assert_started(bot_id)
|
||||
_, cfg2 = api(f"/api/bot/{bot_id}/config")
|
||||
assert cfg2["identity"] == id1, (
|
||||
f"identity changed across restart: {id1} \u2192 {cfg2['identity']}"
|
||||
)
|
||||
run("identity preserved across stop/start", t_identity_persists)
|
||||
|
||||
|
||||
def group_playback(bot_id: str):
|
||||
print("\n== playback ==")
|
||||
|
||||
# Bring the bot online ONCE for the whole playback group, then only
|
||||
# toggle player state (play/pause/stop) between tests. This keeps the
|
||||
# TS3 reconnect count for this group at exactly 1.
|
||||
assert_started(bot_id)
|
||||
|
||||
def t_play_song():
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert s == 200, f"play failed: {d}"
|
||||
time.sleep(1.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"] is True, f"not playing after /play: {b}"
|
||||
assert b["currentSong"] is not None
|
||||
run("play netease song \u2192 playing=true", t_play_song)
|
||||
|
||||
def t_pause_resume():
|
||||
# Previous test left a song playing
|
||||
api(f"/api/player/{bot_id}/pause", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["paused"] is True, f"pause failed: {b}"
|
||||
api(f"/api/player/{bot_id}/resume", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}"
|
||||
run("pause \u2192 paused, resume \u2192 playing", t_pause_resume)
|
||||
|
||||
def t_volume_change():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": 42},
|
||||
)
|
||||
assert s == 200
|
||||
time.sleep(0.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == 42, f"volume not applied: {b['volume']}"
|
||||
run("volume change", t_volume_change)
|
||||
|
||||
def t_mode_cycle():
|
||||
for m in ("seq", "loop", "random", "rloop"):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m}
|
||||
)
|
||||
assert s == 200
|
||||
b = get_bot(bot_id)
|
||||
assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}"
|
||||
run("all four play modes apply", t_mode_cycle)
|
||||
|
||||
def t_queue_endpoint():
|
||||
s, d = api(f"/api/player/{bot_id}/queue")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("queue"), list)
|
||||
assert "status" in d
|
||||
run("GET /player/:id/queue returns queue+status", t_queue_endpoint)
|
||||
|
||||
def t_elapsed_endpoint():
|
||||
s, d = api(f"/api/player/{bot_id}/elapsed")
|
||||
assert s == 200
|
||||
elapsed = d.get("elapsed")
|
||||
assert isinstance(elapsed, (int, float)) and elapsed >= 0, (
|
||||
f"elapsed should be non-negative number: {elapsed}"
|
||||
)
|
||||
run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint)
|
||||
|
||||
def t_add_autoplay_on_idle():
|
||||
# This specific test needs an IDLE bot — stop first (but keep
|
||||
# connected), then add and confirm auto-play.
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}"
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert s == 200
|
||||
msg = d.get("message", "") if isinstance(d, dict) else ""
|
||||
assert "Now playing" in msg, (
|
||||
f"add on idle bot should auto-play, got: {msg!r}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"] is True, f"not playing after add: {b}"
|
||||
run("add on idle bot auto-plays", t_add_autoplay_on_idle)
|
||||
|
||||
# Leave the bot in a clean state for the next group
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
|
||||
|
||||
def group_queue_ops(bot_id: str):
|
||||
print("\n== queue operations ==")
|
||||
assert_started(bot_id)
|
||||
|
||||
def t_clear():
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(0.8)
|
||||
api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "lemon tree", "platform": "netease"},
|
||||
)
|
||||
time.sleep(0.6)
|
||||
b_before = get_bot(bot_id)
|
||||
assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}"
|
||||
api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
time.sleep(0.4)
|
||||
b_after = get_bot(bot_id)
|
||||
assert b_after["queueSize"] == 0
|
||||
run("clear queue empties it", t_clear)
|
||||
|
||||
def t_play_at_invalid_preserves_playback():
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.2)
|
||||
assert get_bot(bot_id)["playing"]
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/play-at",
|
||||
method="POST",
|
||||
json_body={"index": 9999},
|
||||
)
|
||||
assert s == 400, f"invalid index should 400, got {s}"
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"], "invalid play-at killed the current song"
|
||||
run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback)
|
||||
|
||||
def t_play_at_negative_rejected():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/play-at",
|
||||
method="POST",
|
||||
json_body={"index": -1},
|
||||
)
|
||||
assert s == 400
|
||||
run("play-at with negative index rejected", t_play_at_negative_rejected)
|
||||
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
|
||||
|
||||
def group_input_validation(bot_id: str):
|
||||
print("\n== HTTP input validation ==")
|
||||
|
||||
def t_volume_out_of_range():
|
||||
for bad in (150, -10, 1000, -1):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": bad},
|
||||
)
|
||||
assert s == 400, f"volume={bad} should 400, got {s}"
|
||||
run("volume out-of-range rejected (400)", t_volume_out_of_range)
|
||||
|
||||
def t_volume_wrong_type():
|
||||
for bad in ("50", None, [50], {"v": 50}):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": bad},
|
||||
)
|
||||
assert s == 400, f"volume={bad!r} should 400, got {s}"
|
||||
run("volume wrong-type rejected (400)", t_volume_wrong_type)
|
||||
|
||||
def t_volume_missing():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume", method="POST", json_body={}
|
||||
)
|
||||
assert s == 400
|
||||
run("volume missing rejected (400)", t_volume_missing)
|
||||
|
||||
def t_volume_valid():
|
||||
for good in (0, 1, 50, 100):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": good},
|
||||
)
|
||||
assert s == 200, f"volume={good} should succeed, got {s}"
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == good, f"volume not applied: {b['volume']}"
|
||||
run("valid volumes apply", t_volume_valid)
|
||||
|
||||
def t_mode_invalid():
|
||||
for bad in ("bogus", "", None, 1, "SEQ"):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json_body={"mode": bad},
|
||||
)
|
||||
assert s == 400, f"mode={bad!r} should 400, got {s}"
|
||||
run("mode invalid rejected (400)", t_mode_invalid)
|
||||
|
||||
def t_mode_missing():
|
||||
s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={})
|
||||
assert s == 400
|
||||
run("mode missing rejected (400)", t_mode_missing)
|
||||
|
||||
|
||||
def group_disconnect_corners(bot_id: str):
|
||||
print("\n== disconnected-bot corners ==")
|
||||
|
||||
def t_play_rejected():
|
||||
assert stop_and_wait(bot_id)
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "x", "platform": "netease"},
|
||||
)
|
||||
assert s >= 400
|
||||
err = (d.get("error") or "") if isinstance(d, dict) else ""
|
||||
assert "not connected" in err.lower(), f"expected 'not connected' error: {d}"
|
||||
run("play rejected while disconnected", t_play_rejected)
|
||||
|
||||
def t_add_rejected():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "x", "platform": "netease"},
|
||||
)
|
||||
assert s >= 400
|
||||
run("add rejected while disconnected", t_add_rejected)
|
||||
|
||||
def t_next_rejected():
|
||||
s, _ = api(f"/api/player/{bot_id}/next", method="POST")
|
||||
assert s >= 400
|
||||
run("next rejected while disconnected", t_next_rejected)
|
||||
|
||||
def t_volume_allowed():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": 60},
|
||||
)
|
||||
assert s == 200, "volume should work while disconnected"
|
||||
run("volume allowed while disconnected", t_volume_allowed)
|
||||
|
||||
def t_mode_allowed():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"}
|
||||
)
|
||||
assert s == 200, "mode should work while disconnected"
|
||||
run("mode allowed while disconnected", t_mode_allowed)
|
||||
|
||||
def t_clear_allowed():
|
||||
s, _ = api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
assert s == 200, "clear should work while disconnected"
|
||||
run("clear allowed while disconnected", t_clear_allowed)
|
||||
|
||||
def t_player_state_clean():
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"] and not b["paused"], f"state leak: {b}"
|
||||
run("no player state leak while disconnected", t_player_state_clean)
|
||||
|
||||
|
||||
def group_seek(bot_id: str):
|
||||
print("\n== seek validation ==")
|
||||
|
||||
def t_negative():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5}
|
||||
)
|
||||
assert s == 400
|
||||
run("negative seek rejected", t_negative)
|
||||
|
||||
def t_string():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json_body={"position": "abc"},
|
||||
)
|
||||
assert s == 400
|
||||
run("string seek rejected", t_string)
|
||||
|
||||
def t_nan_literal():
|
||||
r = requests.post(
|
||||
f"{BASE}/api/player/{bot_id}/seek",
|
||||
data='{"position": NaN}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}"
|
||||
run("NaN literal seek rejected", t_nan_literal)
|
||||
|
||||
def t_valid_seek():
|
||||
# Seek needs a live connection + playing song. The disconnect-
|
||||
# corners group right before this one left the bot disconnected.
|
||||
assert_started(bot_id)
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.5)
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json_body={"position": 25},
|
||||
)
|
||||
assert s == 200
|
||||
time.sleep(0.5)
|
||||
_, d = api(f"/api/player/{bot_id}/elapsed")
|
||||
elapsed = d.get("elapsed")
|
||||
assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, (
|
||||
f"elapsed after seek(25) wrong: {elapsed}"
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
run("valid seek produces finite elapsed", t_valid_seek)
|
||||
|
||||
|
||||
def group_races(bot_id: str):
|
||||
print("\n== race conditions ==")
|
||||
|
||||
def t_disconnect_during_connect():
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.2)
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
time.sleep(2)
|
||||
b = get_bot(bot_id)
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: {b}"
|
||||
)
|
||||
run("disconnect during connect", t_disconnect_during_connect)
|
||||
|
||||
def t_stop_during_url_resolve():
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
assert_started(bot_id)
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.15)
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(3)
|
||||
b = get_bot(bot_id)
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: {b}"
|
||||
)
|
||||
run("stop during URL resolve", t_stop_during_url_resolve)
|
||||
|
||||
def t_rapid_volume_change():
|
||||
# Volume is a config-only command and works while disconnected,
|
||||
# so this test deliberately doesn't call assert_started — we're
|
||||
# validating the API's last-write-wins behavior, not the TS
|
||||
# transport. That also spares the TS3 anti-flood budget.
|
||||
for v in (10, 25, 50, 75, 100, 1):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": v},
|
||||
)
|
||||
assert s == 200, f"volume POST failed: {s}"
|
||||
time.sleep(0.3)
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == 1, f"final volume wrong: {b['volume']}"
|
||||
run("rapid volume changes converge", t_rapid_volume_change)
|
||||
|
||||
|
||||
def group_websocket(bot_id: str):
|
||||
print("\n== websocket broadcasts ==")
|
||||
try:
|
||||
from websocket import create_connection
|
||||
except Exception as e:
|
||||
skip("websocket state broadcasts", f"websocket lib unavailable: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
ws = create_connection("ws://localhost:3000/ws", timeout=5)
|
||||
except Exception as e:
|
||||
skip("websocket state broadcasts", f"connect failed: {e}")
|
||||
return
|
||||
|
||||
ws.settimeout(0.3)
|
||||
messages: list[dict] = []
|
||||
stop_reader = threading.Event()
|
||||
|
||||
def reader():
|
||||
while not stop_reader.is_set():
|
||||
try:
|
||||
raw = ws.recv()
|
||||
if not raw:
|
||||
break
|
||||
try:
|
||||
messages.append(json.loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# recv() timeout or closed — keep trying until stop_reader
|
||||
if stop_reader.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
reader_thread = threading.Thread(target=reader, daemon=True)
|
||||
reader_thread.start()
|
||||
|
||||
try:
|
||||
def t_init():
|
||||
time.sleep(0.6)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "init" in types, f"no init message; got: {types}"
|
||||
run("init message on connect", t_init)
|
||||
|
||||
def t_state_change_on_play():
|
||||
assert_started(bot_id)
|
||||
messages.clear()
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.5)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "stateChange" in types, (
|
||||
f"no stateChange after play; got types: {types}"
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
run("stateChange broadcast on play", t_state_change_on_play)
|
||||
|
||||
def t_bot_disconnected_event():
|
||||
assert_started(bot_id)
|
||||
messages.clear()
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
time.sleep(1.5)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "botDisconnected" in types or "stateChange" in types, (
|
||||
f"no disconnect event; got: {types}"
|
||||
)
|
||||
run("botDisconnected event on stop", t_bot_disconnected_event)
|
||||
finally:
|
||||
stop_reader.set()
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ----------------------------- main ----------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_, data = api("/api/bot/")
|
||||
if not isinstance(data, dict) or not data.get("bots"):
|
||||
print("[fatal] no bots registered — create one via the WebUI first")
|
||||
return 2
|
||||
|
||||
target = data["bots"][0]
|
||||
bot_id = target["id"]
|
||||
initial_connected = target["connected"]
|
||||
initial_volume = target["volume"]
|
||||
initial_mode = target["playMode"]
|
||||
|
||||
print(f"[init] target bot = {bot_id[:8]} ({target['name']})")
|
||||
print(
|
||||
f"[init] initial state: connected={initial_connected} "
|
||||
f"volume={initial_volume} mode={initial_mode}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Read-only / no-lifecycle groups first — they don't consume TS3
|
||||
# anti-flood budget.
|
||||
group_infrastructure(bot_id)
|
||||
group_auth_status()
|
||||
group_search()
|
||||
|
||||
# Lifecycle-heavy groups — interleave with small breathers so the
|
||||
# TS3 server's per-IP reconnect limit doesn't start throttling us.
|
||||
group_lifecycle(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_playback(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_queue_ops(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_input_validation(bot_id)
|
||||
group_disconnect_corners(bot_id)
|
||||
group_seek(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_races(bot_id)
|
||||
# Extra breather before websocket group — races is the heaviest
|
||||
# consumer of TS3 reconnect budget (disconnect-during-connect and
|
||||
# stop-during-url-resolve each burn one cycle), and the websocket
|
||||
# group needs a clean reconnect to observe live state broadcasts.
|
||||
time.sleep(ANTIFLOOD_BREATHER * 3)
|
||||
|
||||
group_websocket(bot_id)
|
||||
finally:
|
||||
# Restore initial state — this runs even if a test raised
|
||||
try:
|
||||
api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": initial_volume},
|
||||
)
|
||||
api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json_body={"mode": initial_mode},
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
if initial_connected:
|
||||
start_and_wait(bot_id)
|
||||
else:
|
||||
stop_and_wait(bot_id)
|
||||
except Exception as e:
|
||||
print(f"[warn] restore failed: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" PASSED: {passed}")
|
||||
print(f" FAILED: {failed}")
|
||||
print(f" SKIPPED: {skipped}")
|
||||
print("=" * 60)
|
||||
|
||||
if failed > 0:
|
||||
print("\nFailed tests:")
|
||||
for r in results:
|
||||
if r.status in ("FAIL", "ERROR"):
|
||||
print(f" [{r.status}] {r.name}: {r.detail}")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
"""More corner-case regressions.
|
||||
|
||||
A. resolveAndPlay disconnect-during-URL-resolve race
|
||||
The bot checks !this.connected at the top of resolveAndPlay, but the
|
||||
URL-resolve await can take several seconds. If stop is called during
|
||||
that window, playback would previously start on a disconnected bot.
|
||||
|
||||
B. /seek NaN/Infinity rejection
|
||||
typeof NaN === "number" and NaN < 0 is false, so a plain range check
|
||||
leaks NaN through and corrupts seekOffset / getElapsed.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def test_resolve_play_stop_race(bot_id):
|
||||
"""Fire stopBot during the /play call's URL resolve window."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1)
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
|
||||
# Schedule a stop 150ms into the play call — that lands inside the
|
||||
# provider.getSongUrl await, which is where the race lives.
|
||||
def delayed_stop():
|
||||
time.sleep(0.15)
|
||||
try:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
|
||||
try:
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Give both calls time to settle fully
|
||||
time.sleep(3)
|
||||
b = get_bot(bot_id)
|
||||
# Key invariant: we never want connected=false AND playing=true. That
|
||||
# pair is the exact Bug C symptom and would indicate the resolveAndPlay
|
||||
# post-await check didn't fire.
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state after race: {b}"
|
||||
)
|
||||
print(
|
||||
f"[PASS] resolveAndPlay stop-race — final state consistent "
|
||||
f"(connected={b['connected']} playing={b['playing']})"
|
||||
)
|
||||
|
||||
|
||||
def test_seek_nan_rejected(bot_id):
|
||||
"""Verify that NaN and Infinity seek positions are rejected at the API
|
||||
layer (instead of poisoning seekOffset)."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
|
||||
# Start a real song so there is an active playback to seek against
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.2)
|
||||
|
||||
# JSON spec doesn't allow NaN/Infinity literals, but Python's json
|
||||
# encoder emits them as bare tokens when allow_nan=True (the default).
|
||||
# Express's body-parser rejects them as invalid JSON, which itself is
|
||||
# a form of rejection. We additionally verify that sending a string
|
||||
# "NaN" or a negative value is also rejected with a clean 400.
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": -5},
|
||||
)
|
||||
assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": "fifty"},
|
||||
)
|
||||
assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
|
||||
|
||||
# Directly send NaN in raw body (body-parser will likely 400 it)
|
||||
r = requests.post(
|
||||
f"{BASE}/api/player/{bot_id}/seek",
|
||||
data='{"position": NaN}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
|
||||
|
||||
# After the junk attempts, a valid seek still works and the elapsed
|
||||
# time is a finite number (not NaN).
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": 30},
|
||||
)
|
||||
assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
|
||||
|
||||
elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
|
||||
elapsed = elapsed_resp.json().get("elapsed")
|
||||
assert elapsed is not None and isinstance(elapsed, (int, float)), (
|
||||
f"elapsed should be a number, got {elapsed}"
|
||||
)
|
||||
# Could be exactly 30 or a tiny bit more if a frame has advanced
|
||||
assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
|
||||
print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
|
||||
|
||||
try:
|
||||
test_resolve_play_stop_race(bot_id)
|
||||
test_seek_nan_rejected(bot_id)
|
||||
print("ALL GREEN")
|
||||
finally:
|
||||
if initial:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] connected={get_bot(bot_id)['connected']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Stress-test two bots playing music concurrently on the same TS server.
|
||||
|
||||
Creates two temporary bots (or reuses existing named ones), starts them,
|
||||
plays music on both, and polls /api/bot/ every 2 seconds to detect when
|
||||
(if) either bot disconnects or stops playing. Cleans up on exit.
|
||||
|
||||
Usage:
|
||||
python scripts/test_multibot.py --minutes 3
|
||||
python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://localhost:3000"
|
||||
POLL_INTERVAL = 2.0
|
||||
TEST_BOT_NAMES = ("mbtest1", "mbtest2")
|
||||
TEST_BOT_NICKS = ("MBTest1", "MBTest2")
|
||||
QUERIES = ("the mass", "lofi") # one different song per bot
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotSnapshot:
|
||||
t: float
|
||||
connected: bool
|
||||
playing: bool
|
||||
song: str | None
|
||||
|
||||
|
||||
def api_get(path: str):
|
||||
r = requests.get(f"{API}{path}", timeout=5)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_post(path: str, json=None):
|
||||
r = requests.post(f"{API}{path}", json=json, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_delete(path: str):
|
||||
r = requests.delete(f"{API}{path}", timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def cleanup_existing(names: tuple[str, ...]) -> None:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
for b in bots:
|
||||
if b["name"] in names:
|
||||
try:
|
||||
api_post(f"/api/player/{b['id']}/stop")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
api_delete(f"/api/bot/{b['id']}")
|
||||
print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] failed to remove {b['name']}: {e}")
|
||||
|
||||
|
||||
def create_bot(name: str, nickname: str, host: str, port: int) -> str:
|
||||
res = api_post(
|
||||
"/api/bot/",
|
||||
json={
|
||||
"name": name,
|
||||
"serverAddress": host,
|
||||
"serverPort": port,
|
||||
"nickname": nickname,
|
||||
"autoStart": False,
|
||||
},
|
||||
)
|
||||
bot_id = res["id"]
|
||||
print(f"[create] {name} -> {bot_id}")
|
||||
return bot_id
|
||||
|
||||
|
||||
def start_bot(bot_id: str) -> None:
|
||||
api_post(f"/api/bot/{bot_id}/start")
|
||||
|
||||
|
||||
def play(bot_id: str, query: str) -> None:
|
||||
api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
|
||||
|
||||
|
||||
def snapshot(bot_id: str, t0: float) -> BotSnapshot:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
b = next((x for x in bots if x["id"] == bot_id), None)
|
||||
if not b:
|
||||
return BotSnapshot(time.time() - t0, False, False, None)
|
||||
song = b["currentSong"]["name"] if b.get("currentSong") else None
|
||||
return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
|
||||
|
||||
|
||||
def run(minutes: float, host: str, port: int) -> int:
|
||||
print(f"[setup] duration={minutes}min host={host}:{port}")
|
||||
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
|
||||
bot_ids = [
|
||||
create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
|
||||
create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
|
||||
]
|
||||
|
||||
# Start both, allowing a small stagger to avoid handshake collision
|
||||
for i, bid in enumerate(bot_ids):
|
||||
start_bot(bid)
|
||||
print(f"[start] bot{i+1} started")
|
||||
time.sleep(1.5)
|
||||
|
||||
# Wait until both are connected (or bail after 15s)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
|
||||
if all(bots[b]["connected"] for b in bot_ids):
|
||||
print("[start] both bots connected")
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("[fatal] bots did not both come online in 15s")
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
return 2
|
||||
|
||||
# Kick off playback on both
|
||||
for i, bid in enumerate(bot_ids):
|
||||
play(bid, QUERIES[i])
|
||||
print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
|
||||
|
||||
t0 = time.time()
|
||||
end = t0 + minutes * 60
|
||||
first_drop: dict[str, float] = {}
|
||||
last_state: dict[str, BotSnapshot] = {}
|
||||
|
||||
print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
|
||||
print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
|
||||
|
||||
def fmt(snap: BotSnapshot) -> str:
|
||||
flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
|
||||
song = (snap.song or "").replace("\n", " ")[:30]
|
||||
return f"{flag} {song}"
|
||||
|
||||
try:
|
||||
while time.time() < end:
|
||||
snaps = [snapshot(bid, t0) for bid in bot_ids]
|
||||
elapsed = int(time.time() - t0)
|
||||
row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
|
||||
# Only print when state changes or every 10s
|
||||
changed = False
|
||||
for bid, s in zip(bot_ids, snaps):
|
||||
prev = last_state.get(bid)
|
||||
if (prev is None
|
||||
or prev.connected != s.connected
|
||||
or prev.playing != s.playing
|
||||
or prev.song != s.song):
|
||||
changed = True
|
||||
last_state[bid] = s
|
||||
if not s.connected and bid not in first_drop:
|
||||
first_drop[bid] = s.t
|
||||
if changed or elapsed % 10 == 0:
|
||||
print(row)
|
||||
|
||||
# If both stopped playing but are still connected, re-queue the same song
|
||||
for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
|
||||
if s.connected and not s.playing:
|
||||
try:
|
||||
play(bid, QUERIES[i])
|
||||
except Exception as e:
|
||||
print(f"[warn] re-play bot{i+1} failed: {e}")
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[abort] interrupted")
|
||||
|
||||
# Summary
|
||||
total = time.time() - t0
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Total observed time: {total:.1f}s")
|
||||
for i, bid in enumerate(bot_ids):
|
||||
drop = first_drop.get(bid)
|
||||
if drop is None:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
|
||||
else:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
|
||||
print("=" * 60)
|
||||
|
||||
# Cleanup
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
print("[cleanup] done")
|
||||
|
||||
return 0 if not first_drop else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--minutes", type=float, default=3.0)
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=9987)
|
||||
args = p.parse_args()
|
||||
try:
|
||||
return run(args.minutes, args.host, args.port)
|
||||
except requests.HTTPError as e:
|
||||
print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+99
-85
@@ -1,105 +1,119 @@
|
||||
"""Reproduce the player-bar-not-appearing bug."""
|
||||
"""Reproduce / regression-check the player-bar-not-appearing bug.
|
||||
|
||||
Captures the bot's initial playback state and restores it on exit so the
|
||||
test never leaves the user with surprise music or a cleared queue.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
def get_bot_id():
|
||||
r = requests.get(f"{BASE}/api/bot/")
|
||||
return r.json()["bots"][0]["id"]
|
||||
|
||||
def stop(bot_id):
|
||||
requests.post(f"{BASE}/api/player/{bot_id}/stop")
|
||||
requests.post(f"{BASE}/api/player/{bot_id}/clear")
|
||||
def api(path, method="GET", **kw):
|
||||
fn = getattr(requests, method.lower())
|
||||
r = fn(f"{BASE}{path}", timeout=10, **kw)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.text else None
|
||||
|
||||
def play(bot_id, query="test"):
|
||||
requests.post(f"{BASE}/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context()
|
||||
ctx.add_init_script("""
|
||||
(() => {
|
||||
const OrigWS = window.WebSocket;
|
||||
window.__wsMessages = [];
|
||||
window.WebSocket = function(...args) {
|
||||
const ws = new OrigWS(...args);
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
const summary = {type: d.type, botId: d.botId};
|
||||
if (d.status) summary.playing = d.status.playing;
|
||||
if (d.status?.currentSong) summary.song = d.status.currentSong.name;
|
||||
window.__wsMessages.push(summary);
|
||||
console.log('WS_MSG ' + JSON.stringify(summary));
|
||||
} catch(e) {}
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
Object.assign(window.WebSocket, OrigWS);
|
||||
})();
|
||||
""")
|
||||
page = ctx.new_page()
|
||||
logs = []
|
||||
page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}"))
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id)
|
||||
|
||||
bot_id = get_bot_id()
|
||||
stop(bot_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
page.goto(BASE)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.8)
|
||||
def capture_state(bot_id):
|
||||
b = get_bot(bot_id)
|
||||
return {
|
||||
"playing": b["playing"],
|
||||
"paused": b["paused"],
|
||||
"song": (b["currentSong"] or {}).get("name"),
|
||||
}
|
||||
|
||||
# Hook into WebSocket messages from the page to confirm they arrive
|
||||
page.evaluate("""
|
||||
() => {
|
||||
const origWS = window.WebSocket;
|
||||
// Already connected via useWebSocket — tap Pinia store directly
|
||||
// Expose store state via window
|
||||
const store = window.__pinia?._s?.get('player');
|
||||
window.__getStore = () => ({
|
||||
bots: JSON.parse(JSON.stringify(store?.bots || [])),
|
||||
activeBotId: store?.activeBotId,
|
||||
currentSong: store?.currentSong ? JSON.parse(JSON.stringify(store.currentSong)) : null,
|
||||
isPlaying: store?.isPlaying,
|
||||
});
|
||||
}
|
||||
""")
|
||||
|
||||
snap_before = page.evaluate("() => window.__getStore?.()")
|
||||
print("store before:", snap_before)
|
||||
def main():
|
||||
bots = api("/api/bot/")["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = capture_state(bot_id)
|
||||
print(f"[init] initial state: {initial}")
|
||||
|
||||
# Player bar should be absent right now (nothing playing)
|
||||
initial = page.locator(".player-wrapper").count()
|
||||
print(f"initial .player-wrapper count: {initial}")
|
||||
try:
|
||||
# Clear slate
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
time.sleep(0.6)
|
||||
|
||||
# Trigger play via API (simulates any play trigger)
|
||||
play(bot_id, "test")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
ctx = browser.new_context()
|
||||
ctx.add_init_script(
|
||||
"""
|
||||
(() => {
|
||||
const OrigWS = window.WebSocket;
|
||||
window.__wsMessages = [];
|
||||
window.WebSocket = function(...args) {
|
||||
const ws = new OrigWS(...args);
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
window.__wsMessages.push({type: d.type, botId: d.botId});
|
||||
} catch(e) {}
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
Object.assign(window.WebSocket, OrigWS);
|
||||
})();
|
||||
"""
|
||||
)
|
||||
page = ctx.new_page()
|
||||
page.goto(BASE)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.8)
|
||||
|
||||
# Poll for up to 6s to see if player bar appears automatically
|
||||
appeared_at = None
|
||||
for i in range(60):
|
||||
if page.locator(".player-wrapper").count() > 0:
|
||||
appeared_at = i * 0.1
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert page.locator(".player-wrapper").count() == 0, (
|
||||
"player bar should be hidden before playback"
|
||||
)
|
||||
|
||||
print(f"player bar appeared after: {appeared_at}")
|
||||
snap_after = page.evaluate("() => window.__getStore?.()")
|
||||
print("store after:", snap_after)
|
||||
# Trigger play via API (simulates any play trigger)
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
|
||||
# After force reload, does it appear?
|
||||
if appeared_at is None:
|
||||
page.reload()
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.8)
|
||||
after_reload = page.locator(".player-wrapper").count()
|
||||
print(f"after reload .player-wrapper count: {after_reload}")
|
||||
# Poll for up to 6s to see if player bar appears automatically
|
||||
appeared_at = None
|
||||
for i in range(60):
|
||||
if page.locator(".player-wrapper").count() > 0:
|
||||
appeared_at = i * 0.1
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
print("--- console logs ---")
|
||||
for line in logs[-20:]:
|
||||
print(line)
|
||||
if appeared_at is None:
|
||||
msgs = page.evaluate("() => window.__wsMessages")
|
||||
print(f"[FAIL] player bar never appeared; WS msgs: {msgs}")
|
||||
raise AssertionError("player bar did not auto-show on stateChange")
|
||||
print(f"[PASS] player bar appeared after {appeared_at:.1f}s")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
# Restore: stop the "test" song we triggered, then re-apply initial
|
||||
# state as best we can. We can't re-queue the user's previous song,
|
||||
# but we can at least stop ours and leave the bot idle if it was idle.
|
||||
try:
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
except Exception as e:
|
||||
print(f"[warn] failed to stop test song on cleanup: {e}")
|
||||
post = capture_state(bot_id)
|
||||
print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})")
|
||||
if initial["playing"] and initial["song"]:
|
||||
print(
|
||||
f"[note] initial bot was playing {initial['song']!r}; "
|
||||
"this test cannot resume arbitrary tracks — you may need to restart playback"
|
||||
)
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression for the 'connected=False but playing=True' stuck-state bug.
|
||||
|
||||
After rapid disconnect/reconnect, the library could drop the connection
|
||||
(TS3 server anti-flood or a hung handshake). The bot then ended up in an
|
||||
inconsistent state: player.state='playing' but tsClient disconnected.
|
||||
|
||||
This test verifies three fixes:
|
||||
Bug A — startBot() has a 15s timeout instead of hanging forever on a
|
||||
stalled handshake. /start returns a clean 500 instead of blocking.
|
||||
Bug B — play/add/etc commands are rejected when the bot is not connected.
|
||||
Bug C — the tsClient 'disconnected' handler always clears player state,
|
||||
even when connect() never completed (so !this.connected).
|
||||
|
||||
We also sanity-check that the bot recovers (can start a fresh cycle) after
|
||||
a transient failure.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial_connected = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}")
|
||||
|
||||
try:
|
||||
# Start from a clean slate
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
|
||||
# --- Bug B: play while disconnected must be rejected ---
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "rejection test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, (
|
||||
f"play while disconnected should fail, got {r.status_code} {r.text[:120]}"
|
||||
)
|
||||
assert "not connected" in r.text.lower(), (
|
||||
f"expected 'not connected' error, got: {r.text[:200]}"
|
||||
)
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}"
|
||||
print("[PASS] Bug B — /play rejected while bot disconnected; state untouched")
|
||||
|
||||
# --- Bug C: normal start→play→stop leaves player state clean ---
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
assert r.status_code == 200, f"start failed {r.text[:120]}"
|
||||
assert wait_connected(bot_id, True), "bot did not connect within 15s"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code == 200, f"play failed {r.text[:120]}"
|
||||
time.sleep(1.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["connected"] and b["playing"], f"should be connected+playing: {b}"
|
||||
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect"
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], (
|
||||
f"player should have stopped after bot disconnect (Bug C): {b}"
|
||||
)
|
||||
print("[PASS] Bug C — stop clears both connected and playing state")
|
||||
|
||||
# --- Bug A: startBot has a deadline and returns a clean error if connect hangs ---
|
||||
# We can't easily force a hang in-process, but we can sanity-check that
|
||||
# startBot returns promptly (well under the 15s cap) on a normal run.
|
||||
t0 = time.time()
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
elapsed = time.time() - t0
|
||||
assert r.status_code == 200, f"start failed {r.text[:120]}"
|
||||
assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s"
|
||||
assert wait_connected(bot_id, True), "bot did not connect"
|
||||
print(
|
||||
f"[PASS] Bug A — startBot completed in {elapsed:.2f}s "
|
||||
"(deadline is 15s, would throw on hang)"
|
||||
)
|
||||
|
||||
# --- Recovery: after any failure, another start should work ---
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
# Give TS3 server a moment to forget us (anti-flood grace)
|
||||
time.sleep(2)
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
assert r.status_code == 200, f"recovery start failed {r.text[:120]}"
|
||||
assert wait_connected(bot_id, True), "bot did not recover"
|
||||
print("[PASS] recovery — bot reconnects cleanly after a cycle")
|
||||
|
||||
print("ALL GREEN")
|
||||
|
||||
finally:
|
||||
if initial_connected:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user