From df7b944742a6a1ed0e9b90838d3c326f9f111e36 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 01:08:57 +0800 Subject: [PATCH] =?UTF-8?q?test:=20add=20integration=20test=20suite=20?= =?UTF-8?q?=E2=80=94=2074=20tests=20passing=20against=20local=20TS3=20serv?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix @discordjs/opus ESM import for Node 24 compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/audio/encoder.ts | 3 +- test-integration.ts | 499 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 test-integration.ts diff --git a/src/audio/encoder.ts b/src/audio/encoder.ts index a0ec7ac..15bdc38 100644 --- a/src/audio/encoder.ts +++ b/src/audio/encoder.ts @@ -1,4 +1,5 @@ -import { OpusEncoder } from "@discordjs/opus"; +import opusModule from "@discordjs/opus"; +const { OpusEncoder } = opusModule; const SAMPLE_RATE = 48000; const CHANNELS = 2; diff --git a/test-integration.ts b/test-integration.ts new file mode 100644 index 0000000..6576bb2 --- /dev/null +++ b/test-integration.ts @@ -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((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();