test: add integration test suite — 74 tests passing against local TS3 server

Fix @discordjs/opus ESM import for Node 24 compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 01:08:57 +08:00
1 parent 5041a4f3f7
commit df7b944742
2 files changed
+501 -1

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
import { OpusEncoder } from "@discordjs/opus"; import opusModule from "@discordjs/opus";
const { OpusEncoder } = opusModule;
const SAMPLE_RATE = 48000; const SAMPLE_RATE = 48000;
const CHANNELS = 2; const CHANNELS = 2;
+499
View File
@@ -0,0 +1,499 @@
/**
* Integration test: Full stack test against local TeamSpeak server.
* Tests TCP connection, bot lifecycle via Web API, unit components.
*/
import { TS3Connection } from "./src/ts-protocol/connection.js";
import { createLogger } from "./src/logger.js";
import { createDatabase } from "./src/data/database.js";
import { loadConfig } from "./src/data/config.js";
import { NeteaseProvider } from "./src/music/netease.js";
import { QQMusicProvider } from "./src/music/qq.js";
import { BotManager } from "./src/bot/manager.js";
import { createWebServer } from "./src/web/server.js";
import { PlayQueue, PlayMode } from "./src/audio/queue.js";
import { parseCommand, isAdminCommand } from "./src/bot/commands.js";
import {
encodeCommand,
decodeResponse,
escapeValue,
unescapeValue,
} from "./src/ts-protocol/commands.js";
import {
generateIdentity,
exportIdentity,
importIdentity,
} from "./src/ts-protocol/identity.js";
import { createOpusEncoder, PCM_FRAME_BYTES } from "./src/audio/encoder.js";
import { parseLyrics } from "./src/music/netease.js";
const HOST = "127.0.0.1";
const QUERY_PORT = 10011;
const VOICE_PORT = 9987;
const WEB_PORT = 3334;
const logger = createLogger();
let passed = 0;
let failed = 0;
const log = (msg: string) => console.log(`[TEST] ${msg}`);
const pass = (msg: string) => {
console.log(` ✅ ${msg}`);
passed++;
};
const fail = (msg: string) => {
console.log(` ❌ ${msg}`);
failed++;
};
function assert(condition: boolean, msg: string) {
condition ? pass(msg) : fail(msg);
}
// ═══════════════════════════════════════════════════
// Test 1: TS3 Protocol — TCP Connection
// ═══════════════════════════════════════════════════
async function testTcpConnection() {
log("═══ Test 1: TCP ServerQuery Connection ═══");
const conn = new TS3Connection({ host: HOST, port: QUERY_PORT });
await conn.connect();
assert(conn.isConnected(), "Connected to ServerQuery port 10011");
const useResult = await conn.send("use", { sid: 1 });
assert(useResult.errorId === 0, `SELECT virtual server: ok`);
const nickResult = await conn.send("clientupdate", {
client_nickname: "TSMusicBot-Test1",
});
assert(nickResult.errorId === 0, `Set nickname to TSMusicBot-Test1`);
const whoami = await conn.send("whoami");
assert(whoami.errorId === 0, `whoami: client_id=${whoami.data[0]?.client_id}`);
const version = await conn.send("version");
assert(version.errorId === 0, `Server version: ${version.data[0]?.version}`);
conn.disconnect();
assert(!conn.isConnected(), "Disconnected cleanly");
}
// ═══════════════════════════════════════════════════
// Test 2: TS3 Protocol — Command Encoding
// ═══════════════════════════════════════════════════
function testCommandEncoding() {
log("\n═══ Test 2: TS3 Command Encoding/Decoding ═══");
assert(
escapeValue("hello world") === "hello\\sworld",
"Escape spaces"
);
assert(
escapeValue("foo|bar") === "foo\\pbar",
"Escape pipes"
);
assert(
unescapeValue("hello\\sworld") === "hello world",
"Unescape spaces"
);
const encoded = encodeCommand("login", {
client_login_name: "bot",
client_login_password: "test pass",
});
assert(
encoded.includes("client_login_password=test\\spass"),
"Encoded command escapes spaces in values"
);
const decoded = decodeResponse(
"clid=1 client_nickname=User1|clid=2 client_nickname=User2"
);
assert(decoded.length === 2, "Decoded 2 entries from piped response");
assert(decoded[0].client_nickname === "User1", "First entry nickname");
assert(decoded[1].clid === "2", "Second entry clid");
}
// ═══════════════════════════════════════════════════
// Test 3: TS3 Identity
// ═══════════════════════════════════════════════════
function testIdentity() {
log("\n═══ Test 3: TS3 Identity Generation ═══");
const id = generateIdentity();
assert(id.publicKey.length === 32, "Public key is 32 bytes");
assert(id.privateKey.length === 64, "Private key is 64 bytes");
assert(id.uid.length > 0, `UID generated: ${id.uid.slice(0, 20)}...`);
const exported = exportIdentity(id);
const imported = importIdentity(exported);
assert(imported.uid === id.uid, "Export/import roundtrip preserves UID");
const id2 = generateIdentity();
assert(id.uid !== id2.uid, "Two identities have different UIDs");
}
// ═══════════════════════════════════════════════════
// Test 4: Command Parser
// ═══════════════════════════════════════════════════
function testCommandParser() {
log("\n═══ Test 4: Command Parser ═══");
const cmd = parseCommand("!play 晴天 周杰伦", "!");
assert(cmd !== null && cmd.name === "play", "Parse !play");
assert(cmd!.args === "晴天 周杰伦", "Args preserved with spaces");
const cmd2 = parseCommand("!play -q 七里香", "!");
assert(cmd2!.flags.has("q"), "Flag -q parsed");
assert(cmd2!.args === "七里香", "Args after flag");
const cmd3 = parseCommand("!p 稻香", "!", { p: "play", s: "skip" });
assert(cmd3!.name === "play", "Alias p→play resolved");
assert(parseCommand("hello", "!") === null, "Non-command → null");
assert(parseCommand("", "!") === null, "Empty → null");
assert(parseCommand("!vol 80", "!")!.args === "80", "!vol 80");
assert(parseCommand("!mode loop", "!")!.args === "loop", "!mode loop");
assert(isAdminCommand("vol"), "vol is admin command");
assert(!isAdminCommand("play"), "play is public command");
}
// ═══════════════════════════════════════════════════
// Test 5: Play Queue
// ═══════════════════════════════════════════════════
function testPlayQueue() {
log("\n═══ Test 5: Play Queue ═══");
const q = new PlayQueue();
assert(q.isEmpty(), "Starts empty");
const songs = ["Song A", "Song B", "Song C"].map((name, i) => ({
id: String(i),
name,
artist: "Artist",
album: "Album",
platform: "netease" as const,
url: `http://example.com/${i}.mp3`,
coverUrl: "",
duration: 180,
}));
for (const s of songs) q.add(s);
assert(q.size() === 3, "Queue has 3 songs");
q.play();
assert(q.current()?.name === "Song A", "Play → Song A");
q.next();
assert(q.current()?.name === "Song B", "Next → Song B");
q.prev();
assert(q.current()?.name === "Song A", "Prev → Song A");
q.setMode(PlayMode.Loop);
q.playAt(2);
assert(q.current()?.name === "Song C", "PlayAt(2) → Song C");
q.next();
assert(q.current()?.name === "Song A", "Loop wraps to Song A");
q.setMode(PlayMode.Sequential);
q.playAt(2);
assert(q.next() === null, "Sequential: end → null");
q.remove(0);
assert(q.size() === 2, "Remove reduces size");
q.clear();
assert(q.isEmpty(), "Clear empties queue");
}
// ═══════════════════════════════════════════════════
// Test 6: Opus Encoder
// ═══════════════════════════════════════════════════
function testOpusEncoder() {
log("\n═══ Test 6: Opus Encoder ═══");
const enc = createOpusEncoder();
const silence = Buffer.alloc(PCM_FRAME_BYTES, 0);
const opus = enc.encode(silence);
assert(opus.length > 0, `Encoded 20ms silence → ${opus.length} bytes`);
assert(opus.length < PCM_FRAME_BYTES, "Opus smaller than raw PCM");
const decoded = enc.decode(opus);
assert(decoded.length === PCM_FRAME_BYTES, `Decoded back to ${decoded.length} bytes`);
}
// ═══════════════════════════════════════════════════
// Test 7: Database
// ═══════════════════════════════════════════════════
function testDatabase() {
log("\n═══ Test 7: Database ═══");
const db = createDatabase(":memory:");
db.addPlayHistory({
botId: "test",
songId: "123",
songName: "晴天",
artist: "周杰伦",
album: "叶惠美",
platform: "netease",
coverUrl: "",
});
const history = db.getPlayHistory("test", 10);
assert(history.length === 1 && history[0].songName === "晴天", "Play history saved/loaded");
assert(history[0].playedAt !== undefined, `playedAt auto-generated: ${history[0].playedAt}`);
db.saveBotInstance({
id: "b1",
name: "Bot",
serverAddress: "127.0.0.1",
serverPort: 9987,
nickname: "Bot",
defaultChannel: "",
channelPassword: "",
autoStart: false,
});
assert(db.getBotInstances().length === 1, "Bot instance saved");
assert(db.deleteBotInstance("b1"), "Bot instance deleted");
assert(db.getBotInstances().length === 0, "No instances after delete");
db.close();
}
// ═══════════════════════════════════════════════════
// Test 8: Lyrics Parser
// ═══════════════════════════════════════════════════
function testLyricsParser() {
log("\n═══ Test 8: Lyrics Parser ═══");
const lrc = `[00:00.00] 作词 : 周杰伦
[00:12.50]故事的小黄花
[00:15.80]从出生那年就飘着`;
const lines = parseLyrics(lrc);
assert(lines.length === 2, "Skipped metadata, got 2 lyric lines");
assert(Math.abs(lines[0].time - 12.5) < 0.1, `Line 1 time: ${lines[0].time}`);
assert(lines[0].text === "故事的小黄花", "Line 1 text");
const tlyric = "[00:12.50]故事的小黄花 (translation)";
const merged = parseLyrics("[00:12.50]Hello", tlyric);
assert(merged[0].translation !== undefined, "Translation merged");
assert(parseLyrics("").length === 0, "Empty lyrics → empty array");
}
// ═══════════════════════════════════════════════════
// Test 9: Full Stack — Web API + Bot + TS Connection
// ═══════════════════════════════════════════════════
async function testFullStack() {
log("\n═══ Test 9: Full Stack (Web API → Bot → TeamSpeak) ═══");
const db = createDatabase(":memory:");
const config = loadConfig("nonexistent");
const neteaseProvider = new NeteaseProvider("http://127.0.0.1:3001");
const qqProvider = new QQMusicProvider("http://127.0.0.1:3002");
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
const web = createWebServer({
port: WEB_PORT,
botManager,
neteaseProvider,
qqProvider,
database: db,
config,
logger,
});
await web.start();
log(` Web server on port ${WEB_PORT}`);
// Health check
const health = await fetch(`http://127.0.0.1:${WEB_PORT}/api/health`).then((r) => r.json());
assert(health.status === "ok", `Health: ${JSON.stringify(health)}`);
// Bot list (empty)
const list0 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`).then((r) => r.json());
assert(list0.bots.length === 0, "No bots initially");
// Create bot
const createRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Test Bot",
serverAddress: HOST,
serverPort: VOICE_PORT,
nickname: "MusicBot-FullTest",
autoStart: false,
}),
});
const botData = await createRes.json();
assert(createRes.status === 201, `Created bot: ${botData.id}`);
const botId = botData.id;
// Start bot (connect to TS)
log(" Connecting bot to TeamSpeak...");
const startRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}/start`, {
method: "POST",
});
const startData = await startRes.json();
assert(startData.success === true, "Bot connected to TeamSpeak!");
// Verify connected
const status = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`).then((r) =>
r.json()
);
assert(status.connected === true, `Bot status: connected=${status.connected}`);
assert(status.name === "Test Bot", `Bot name: ${status.name}`);
assert(status.volume === 75, `Default volume: ${status.volume}`);
assert(status.playing === false, "Not playing (no song loaded)");
// Queue endpoint
const queueRes = await fetch(
`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/queue`
).then((r) => r.json());
assert(Array.isArray(queueRes.queue), `Queue: ${queueRes.queue.length} items`);
assert(queueRes.status.id === botId, "Queue status has correct botId");
// Volume control
const volRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/volume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ volume: 50 }),
}).then((r) => r.json());
assert(volRes.message === "Volume set to 50%", `Volume: ${volRes.message}`);
// Mode control
const modeRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/mode`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "loop" }),
}).then((r) => r.json());
assert(modeRes.message === "Play mode set to: loop", `Mode: ${modeRes.message}`);
// Pause (no track, should still respond)
const pauseRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/pause`, {
method: "POST",
}).then((r) => r.json());
assert(pauseRes.message === "Paused", `Pause: ${pauseRes.message}`);
// Now (nothing playing)
// This would go through bot.executeCommand which requires the bot instance
// Test it via the queue status
const status2 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`).then((r) =>
r.json()
);
assert(status2.volume === 50, "Volume persisted to 50");
assert(status2.playMode === "loop", "Mode persisted to loop");
// Stop bot
const stopRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}/stop`, {
method: "POST",
}).then((r) => r.json());
assert(stopRes.success === true, "Bot stopped");
// Delete bot
const delRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`, {
method: "DELETE",
}).then((r) => r.json());
assert(delRes.success === true, "Bot deleted");
// Verify empty
const list1 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`).then((r) => r.json());
assert(list1.bots.length === 0, "No bots after cleanup");
web.stop();
botManager.shutdown();
db.close();
}
// ═══════════════════════════════════════════════════
// Test 10: WebSocket Connection
// ═══════════════════════════════════════════════════
async function testWebSocket() {
log("\n═══ Test 10: WebSocket Real-time ═══");
const db = createDatabase(":memory:");
const config = loadConfig("nonexistent");
const neteaseProvider = new NeteaseProvider("http://127.0.0.1:3001");
const qqProvider = new QQMusicProvider("http://127.0.0.1:3002");
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
const web = createWebServer({
port: WEB_PORT + 1,
botManager,
neteaseProvider,
qqProvider,
database: db,
config,
logger,
});
await web.start();
// Connect WebSocket
const { WebSocket: WS } = await import("ws");
const ws = new WS(`ws://127.0.0.1:${WEB_PORT + 1}/ws`);
const messages: any[] = [];
// Register message handler BEFORE connection opens
ws.on("message", (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve, reject) => {
ws.on("open", () => resolve());
ws.on("error", reject);
setTimeout(() => reject(new Error("WS timeout")), 3000);
});
assert(ws.readyState === WS.OPEN, "WebSocket connected");
// Wait a moment for init message
await new Promise((r) => setTimeout(r, 500));
assert(messages.length > 0, `Received ${messages.length} message(s)`);
assert(messages[0].type === "init", `Message type: ${messages[0].type}`);
assert(Array.isArray(messages[0].bots), "Init contains bots array");
ws.close();
web.stop();
botManager.shutdown();
db.close();
}
// ═══════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════
async function main() {
console.log("╔═══════════════════════════════════════════════╗");
console.log("║ TSMusicBot Integration Test Suite ║");
console.log("║ TS Server: localhost:9987 / query:10011 ║");
console.log("╚═══════════════════════════════════════════════╝\n");
try {
await testTcpConnection();
testCommandEncoding();
testIdentity();
testCommandParser();
testPlayQueue();
testOpusEncoder();
testDatabase();
testLyricsParser();
await testFullStack();
await testWebSocket();
} catch (err) {
fail(`Unexpected error: ${(err as Error).message}`);
console.error(err);
}
console.log("\n╔═══════════════════════════════════════════════╗");
console.log(`║ Results: ${passed} passed, ${failed} failed ║`);
console.log("╚═══════════════════════════════════════════════╝");
// Give a moment for cleanup
setTimeout(() => process.exit(failed > 0 ? 1 : 0), 500);
}
main();