Skip to main content
The bot-e2e package provides end-to-end integration tests for the Discord bot using a selfbot client to simulate real user interactions.

Overview

These tests verify that bot commands work correctly in a real Discord environment by:
  1. Creating test channels and messages
  2. Invoking bot commands via Discord API
  3. Verifying bot responses (reactions, embeds, database changes)

Tech Stack

  • discord.js-selfbot-v13 - Selfbot client for simulating user actions
  • Effect - Functional effects for composable test operations
  • Vitest with @effect/vitest - Test framework with Effect integration
  • Convex - Database verification

Project Structure

src/
├── core/
│   ├── selfbot-service.ts    # Discord selfbot wrapper
│   ├── assertions.ts         # Custom Effect-based assertions
│   ├── test-channels.ts      # Test channel configuration
│   ├── runtime.ts            # Test runtime setup
│   └── index.ts              # Exports
├── scripts/
│   ├── setup-server.ts       # Create test channels
│   ├── teardown-server.ts    # Delete test server
│   └── wipe-server.ts        # Clean test channels
tests/
├── mark-solution.test.ts     # Mark solution command test
└── smoke.test.ts             # Basic connectivity test

Selfbot Service

Location: src/core/selfbot-service.ts The selfbot service wraps discord.js-selfbot-v13 with Effect for functional composition.

Key Features

const selfbot = yield* Selfbot;

// Get guild by name
const guild = yield* selfbot.getGuild("Test Server");

// Get text channel
const channel = yield* selfbot.getTextChannel(guild, "test-channel");

Error Handling

The selfbot service uses typed Effect errors:
export class SelfbotError extends Data.TaggedError("SelfbotError")<{
  cause: unknown;
}> {}

export class GuildNotFoundError extends Data.TaggedError("GuildNotFoundError")<{
  guildName: string;
  availableGuilds: ReadonlyArray<string>;
}> {}

export class ChannelNotFoundError extends Data.TaggedError("ChannelNotFoundError")<{
  channelName: string;
  availableChannels: ReadonlyArray<string>;
}> {}

export class CommandNotFoundError extends Data.TaggedError("CommandNotFoundError")<{
  commandName: string;
  commandType: number;
  availableCommands: ReadonlyArray<string>;
}> {}

Writing Tests

Mark Solution Test

Location: tests/mark-solution.test.ts
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import {
  CHANNELS,
  E2ELayer,
  GUILD_NAME,
  Selfbot,
  waitForReaction,
} from "../src/core";

const MARK_SOLUTION_COMMAND = "✅ Mark Solution";

it.scopedLive(
  "should mark a message as solution and add reaction",
  () =>
    Effect.gen(function* () {
      const selfbot = yield* Selfbot;
      
      // Login as test user
      yield* selfbot.client.login();
      
      // Get test guild and channel
      const guild = yield* selfbot.getGuild(GUILD_NAME);
      const channel = yield* selfbot.getTextChannel(
        guild,
        CHANNELS.MARK_SOLUTION_ENABLED
      );
      
      console.log(`Using guild: ${guild.name} (${guild.id})`);
      console.log(`Using channel: ${channel.name} (${channel.id})`);
      
      // Create test message and thread
      const timestamp = new Date().toISOString();
      const message = yield* selfbot.sendMessage(
        channel,
        `E2E Test Question - ${timestamp}`
      );
      expect(message.id).toBeDefined();
      
      const thread = yield* selfbot.createThread(
        message,
        `E2E Test Thread ${timestamp}`
      );
      expect(thread.id).toBeDefined();
      
      // Send solution message
      const threadMessage = yield* selfbot.sendMessage(
        thread,
        `This is the answer to mark as solved - ${timestamp}`
      );
      expect(threadMessage.id).toBeDefined();
      
      // Find and invoke mark solution command
      const command = yield* selfbot.findCommand(
        guild.id,
        MARK_SOLUTION_COMMAND,
        3  // MESSAGE context menu
      );
      
      yield* selfbot.invokeMessageContextMenu(
        guild.id,
        thread.id,
        threadMessage.id,
        command
      );
      
      console.log("Command invoked, waiting for bot response...");
      
      // Wait for bot to add checkmark reaction
      const hasReaction = yield* waitForReaction(threadMessage, "✅", {
        timeout: "25 seconds",
      }).pipe(
        Effect.map(() => true),
        Effect.catchTag("WaitTimeoutError", () => {
          console.log("❌ Bot did not add reaction within timeout");
          console.log("This could mean:");
          console.log("  - Bot is not running");
          console.log("  - Channel doesn't have mark solution enabled");
          console.log("  - Bot doesn't have permission to react");
          return Effect.succeed(false);
        }),
      );
      
      expect(hasReaction).toBe(true);
      console.log("✅ Bot added checkmark reaction to solution message");
    }).pipe(Effect.provide(E2ELayer)),
  { timeout: 30000 }
);

Smoke Test

Location: tests/smoke.test.ts Basic connectivity test to verify the bot is running:
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { E2ELayer, Selfbot, GUILD_NAME } from "../src/core";

it.scopedLive(
  "should connect to Discord and find test guild",
  () =>
    Effect.gen(function* () {
      const selfbot = yield* Selfbot;
      
      yield* selfbot.client.login();
      
      const guild = yield* selfbot.getGuild(GUILD_NAME);
      
      expect(guild).toBeDefined();
      expect(guild.name).toBe(GUILD_NAME);
      
      console.log(`✅ Connected to guild: ${guild.name}`);
    }).pipe(Effect.provide(E2ELayer)),
  { timeout: 10000 }
);

Assertions

Location: src/core/assertions.ts Custom Effect-based assertions for async operations:
import { Effect, Data } from "effect";
import type { Message } from "discord.js-selfbot-v13";

export class WaitTimeoutError extends Data.TaggedError("WaitTimeoutError")<{
  message: string;
}> {}

export const waitForReaction = (
  message: Message,
  emoji: string,
  options: { timeout: string }
): Effect.Effect<void, WaitTimeoutError> =>
  Effect.gen(function* () {
    const startTime = Date.now();
    const timeoutMs = parseTimeout(options.timeout);
    
    while (Date.now() - startTime < timeoutMs) {
      // Fetch fresh message
      const fresh = yield* Effect.tryPromise(() =>
        message.fetch()
      );
      
      // Check if reaction exists
      const reaction = fresh.reactions.cache.find(
        (r) => r.emoji.name === emoji
      );
      
      if (reaction) {
        return;
      }
      
      // Wait 500ms before checking again
      yield* Effect.sleep("500 millis");
    }
    
    // Timeout reached
    return yield* Effect.fail(
      new WaitTimeoutError({
        message: `Reaction ${emoji} not found after ${options.timeout}`,
      })
    );
  });

Test Configuration

File: vitest.config.mts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    setupFiles: ["./src/core/runtime.ts"],
    testTimeout: 30000,  // 30s default timeout
    hookTimeout: 10000,  // 10s for setup/teardown
  },
});

Test Channels

Location: src/core/test-channels.ts Defines the test channel structure:
export const GUILD_NAME = "Answer Overflow E2E Tests";

export const CHANNELS = {
  MARK_SOLUTION_ENABLED: "mark-solution-enabled",
  MARK_SOLUTION_DISABLED: "mark-solution-disabled",
  AUTO_THREAD_ENABLED: "auto-thread-enabled",
  FORUM_CHANNEL: "forum-test",
} as const;

Setup & Teardown Scripts

1

Setup Server

Creates test guild with all required channels:
bun run setup
Script: src/scripts/setup-server.ts
  • Creates category “E2E Tests”
  • Creates text channels for each test scenario
  • Creates forum channels
  • Configures channel settings in database
2

Wipe Server

Deletes all test channels but keeps the guild:
bun run wipe
Script: src/scripts/wipe-server.tsUseful for cleaning up after failed tests.
3

Teardown Server

Completely deletes the test guild:
bun run teardown
Script: src/scripts/teardown-server.ts⚠️ Use with caution - this is destructive!

Running Tests

bun run test

Environment Variables

.env.production
# Discord credentials
DISCORD_E2E_TOKEN=your_user_token  # User token, NOT bot token

# Convex (for database verification)
NEXT_PUBLIC_CONVEX_URL=https://xxx.convex.cloud
CONVEX_DEPLOY_KEY=your_deploy_key

# Optional: Pushover for test failure alerts
PUSHOVER_USER_KEY=your_pushover_user
PUSHOVER_API_TOKEN=your_pushover_token
⚠️ Important: Use a user token, not a bot token. You can get this from your Discord client’s developer tools.

Continuous Integration

The tests run automatically on Railway: File: railway.toml
[build]
builder = "nixpacks"
buildCommand = "bun install"

[deploy]
startCommand = "bun run start"
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3

[env]
DISCORD_E2E_TOKEN = "$DISCORD_E2E_TOKEN"
NEXT_PUBLIC_CONVEX_URL = "$NEXT_PUBLIC_CONVEX_URL"

Test Schedule

Tests run on a cron schedule to continuously verify bot functionality:
run-tests.ts
import { Effect } from "effect";
import { runAllTests } from "./tests";
import { sendAlert } from "./core/alerting";

const main = Effect.gen(function* () {
  console.log("Running scheduled E2E tests...");
  
  const results = yield* runAllTests();
  
  if (results.failures > 0) {
    yield* sendAlert({
      title: "E2E Tests Failed",
      message: `${results.failures}/${results.total} tests failed`,
      priority: "high",
    });
  }
  
  console.log(`✅ ${results.passed}/${results.total} tests passed`);
});

Best Practices

  • Use unique timestamps in test data
  • Clean up resources after tests
  • Don’t rely on test execution order

Debugging Tests

Enable Verbose Logging

DEBUG=discord:* bun run test

Run Single Test

it.only("should test specific behavior", () => { ... });

Check Database State

Query Convex directly to verify database changes:
import { convex } from "@packages/database";

const message = yield* Effect.tryPromise(() =>
  convex.query(api.messages.getByDiscordId, {
    discordId: BigInt(messageId),
  })
);

expect(message?.solutionId).toBe(solutionMessageId);