test/discord.e2e-spec.ts
Rama dev · réplica de lectura del repositorio original.
/**
* End-to-end tests for the Discord voice connection and audio playback: real
* bots join a real voice channel and play audio; a second bot (the listener,
* in its own process) records what Discord relays and compares it against the
* source signal with leniency.
*
* Requires `DISCORD_CLIENT_TOKEN`, `E2E_LISTENER_TOKEN`, `E2E_DISCORD_GUILD_ID`
* and `E2E_DISCORD_VOICE_CHANNEL_ID`. The suite is skipped when unconfigured.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
VoiceConnectionStatus,
entersState,
getVoiceConnection,
} from '@discordjs/voice';
import { VoiceChannel } from 'discord.js';
import { Track } from '../src/models/track';
import { EventNames } from '../src/events/names';
import { createBotUnderTest, BotUnderTest } from './helpers/e2e/bot-app';
import { DiscordListener } from './helpers/e2e/discord-listener';
import { MockJellyfinServer } from './helpers/e2e/mock-jellyfin-server';
import {
compareAudio,
pearsonCorrelation,
AudioComparison,
} from './helpers/e2e/audio-analysis';
import { generateMelodySignal } from './helpers/e2e/audio-signal';
import {
e2eConfigError,
isE2eConfigured,
loadE2eConfig,
} from './helpers/e2e/e2e-env';
import { preflightDiscordToken } from './helpers/e2e/preflight';
const config = loadE2eConfig();
const enabled = isE2eConfigured(config);
// Three distinct melodies so each playlist track is individually attributable.
const melodies = [
generateMelodySignal({
notes: [523.25, 659.25, 783.99, 880],
amplitude: 0.45,
}),
generateMelodySignal({
notes: [392, 523.25, 659.25, 783.99],
amplitude: 0.45,
}),
generateMelodySignal({
notes: [659.25, 783.99, 1046.5, 880],
amplitude: 0.45,
}),
];
const PLAY_TRACK_IDS = ['e2e-track-1', 'e2e-track-2', 'e2e-track-3'];
const e2e = {
bot: undefined as unknown as BotUnderTest,
listener: undefined as unknown as DiscordListener,
mockServer: undefined as unknown as MockJellyfinServer,
};
describe.runIf(enabled)('Discord bot e2e', () => {
beforeAll(async () => {
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
// Fail fast on credential/connectivity issues instead of a generic boot timeout.
await preflightDiscordToken(
config.botToken,
'Bot under test (DISCORD_CLIENT_TOKEN)',
);
await preflightDiscordToken(
config.listenerToken,
'Listener bot (E2E_LISTENER_TOKEN)',
);
e2e.mockServer = new MockJellyfinServer(
melodies.map((melody, index) => ({
id: PLAY_TRACK_IDS[index],
audio: melody.oggOpus,
})),
);
await e2e.mockServer.start();
e2e.bot = await createBotUnderTest(config, e2e.mockServer.baseUrl);
e2e.listener = new DiscordListener();
await e2e.listener.login(config.listenerToken);
await e2e.listener.joinVoiceChannel(config.guildId, config.voiceChannelId);
}, 90_000);
afterAll(async () => {
// Leave the voice channel so neither bot lingers in it.
if (e2e.bot) {
e2e.bot.voiceService.leaveChannel();
}
await e2e.listener?.destroy();
if (e2e.bot) {
await e2e.bot.destroy();
}
await e2e.mockServer?.close();
});
describe('Discord connection', () => {
it('logs into the Discord gateway and reaches the Ready state', () => {
expect(e2e.bot.client.isReady()).toBe(true);
expect(e2e.bot.client.ws.status).toBe(0);
}, 15_000);
it('joins the configured voice channel and the listener can see it', async () => {
const guild = e2e.bot.client.guilds.cache.get(config.guildId);
if (!guild) {
throw new Error('bot should be a member of the configured guild');
}
const fetched = await guild.channels.fetch(config.voiceChannelId);
const channel = fetched instanceof VoiceChannel ? fetched : undefined;
if (!channel) {
throw new Error(
'configured voice channel should exist and be a voice channel',
);
}
const botUser = e2e.bot.client.user;
if (!botUser) {
throw new Error('bot user is unavailable');
}
const botUserId = botUser.id;
const seenJoin = e2e.listener.waitForBotVoiceState(botUserId, 10_000);
e2e.bot.voiceService.joinChannel(
config.guildId,
config.voiceChannelId,
channel.guild.voiceAdapterCreator,
);
const connection = getVoiceConnection(config.guildId);
if (!connection) {
throw new Error('bot should hold a voice connection');
}
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
await seenJoin;
}, 30_000);
});
describe('Audio playback', () => {
it('plays a playlist of three tracks via the app and the listener receives all of them without distortion', async () => {
const botUser = e2e.bot.client.user;
if (!botUser) {
throw new Error('bot user is unavailable');
}
const botUserId = botUser.id;
// Enqueue through the app's real auto-advance path (`AnnounceTrack` →
// `DiscordVoiceService` → audio player -> next track).
const tracks = PLAY_TRACK_IDS.map(
(id, index) => new Track(id, `e2e melody ${index + 1}`, 2000),
);
const eventEmitter = e2e.bot.eventEmitter;
let finishedTracks = 0;
const onFinish = () => {
finishedTracks += 1;
};
eventEmitter.on(EventNames.Circuit.FinishedTrack, onFinish);
// 12s is a hard cap so a silent run fails fast instead of hanging.
const recording = e2e.listener.startRecording(botUserId, 1000, 12_000);
await new Promise((resolve) => setTimeout(resolve, 250));
e2e.bot.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
const result = await recording;
const playlist = e2e.bot.playbackService.getPlaylistOrDefault();
const trackComparisons = comparePlaylistTracks(melodies, result.pcm);
// eslint-disable-next-line no-console
console.log(
'Audio comparison metrics (one per playlist track)',
trackComparisons.map((c) => {
const meta = c as AudioComparison & { offsetMs?: number };
return {
similarity: c.similarity.toFixed(4),
errorRmsDb: c.errorRmsDb.toFixed(2),
clicks: c.clicks,
silenceRatio: c.silenceRatio.toFixed(4),
offsetMs: meta.offsetMs,
};
}),
{
finishedTracks,
activeTrackIndex: playlist.activeTrackIndex,
receivedDurationMs: ((result.pcm.length / 48000) * 1000).toFixed(0),
},
);
expect(
result.frames.length,
'the listener should have received Opus audio packets',
).toBeGreaterThan(0);
// Discord reliably relays audio only after the first ~100-300ms of the
// first track (while its SSRC/opus stream is established), so the first
// track always arrives truncated. Enforce strict similarity on tracks 2/3;
// on track 1 just require that audio was actually delivered.
for (const [index, comparison] of trackComparisons.entries()) {
if (index === 0) {
expect(comparison.silenceRatio).toBeLessThan(0.5);
expect(comparison.receivedDurationMs).toBeGreaterThan(1000);
continue;
}
expect(
comparison.failures,
`track ${index + 1}: ${comparison.failures.join('\n') || 'ok'}`,
).toEqual([]);
}
expect(playlist.getLength()).toBe(3);
expect(playlist.activeTrackIndex).toBe(2);
eventEmitter.off(EventNames.Circuit.FinishedTrack, onFinish);
}, 60_000);
});
});
if (!enabled) {
describe('Discord bot e2e', () => {
it('skipped (not configured)', () => {
// eslint-disable-next-line no-console
console.warn(e2eConfigError(config));
}, 5_000);
});
}
/**
* Compares each reference melody against its segment of the recording.
*
* Tracks auto-advance with no silence gap, so they're located by sliding a
* TRIMMED reference (first ~200ms skipped) over the recording and taking the
* best-correlated non-overlapping match per track. Trimming keeps the score
* honest when Discord dropped the lead of the first track.
*/
function comparePlaylistTracks(
refs: import('./helpers/e2e/audio-signal').ReferenceSignal[],
received: Float32Array,
): AudioComparison[] {
const LEAD_SKIP_MS = 0.2;
const leadSkip = Math.round(LEAD_SKIP_MS * 48000);
const results: AudioComparison[] = [];
let searchFrom = 0;
for (const ref of refs) {
const expectedTrim = ref.pcm.subarray(leadSkip);
const searchEnd = Math.min(
received.length - expectedTrim.length,
searchFrom + ref.pcm.length + Math.round(0.6 * 48000),
);
let bestOffset = searchFrom;
let bestScore = -1;
const step = 96; // 2ms sliding step
for (let o = searchFrom; o <= searchEnd; o += step) {
const score = pearsonCorrelation(
expectedTrim,
received.subarray(o, o + expectedTrim.length),
);
if (score > bestScore) {
bestScore = score;
bestOffset = o;
}
}
const segmentEnd = Math.min(
received.length,
bestOffset + expectedTrim.length + Math.round(0.2 * 48000),
);
const comparison = compareAudio(
expectedTrim,
received.slice(bestOffset, segmentEnd),
);
(comparison as AudioComparison & { offsetMs?: number }).offsetMs =
Math.round(bestOffset / 48);
results.push(comparison);
searchFrom = bestOffset + expectedTrim.length - leadSkip;
}
return results;
}