Ctrl K
ComunidadGuías…

test/discord.e2e-spec.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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;
}