diff --git a/.github/workflows/playwright_tests.yml b/.github/workflows/playwright_tests.yml new file mode 100644 index 00000000..af84098a --- /dev/null +++ b/.github/workflows/playwright_tests.yml @@ -0,0 +1,144 @@ +name: Playwright Tests + +on: + workflow_call: + inputs: + environment: + description: 'Environment to run tests against' + required: false + type: string + default: 'test' + push: + branches: + - main + pull_request: + types: [opened, synchronize] + +env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/coreyja_test + APP_BASE_URL: http://localhost:3000 + SQLX_OFFLINE: true + # Required environment variables for app startup (using test/fake values) + OPEN_AI_API_KEY: test-openai-api-key + ANTHROPIC_API_KEY: test-anthropic-api-key + GITHUB_APP_ID: 123456 + GITHUB_APP_CLIENT_ID: test-github-client-id + GITHUB_APP_CLIENT_SECRET: test-github-client-secret + GITHUB_PERSONAL_ACCESS_TOKEN: test-github-pat + GITHUB_APP_PRIVATE_KEY: | + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEA0Z3VS0JJcds3xg + -----END RSA PRIVATE KEY----- + TWITCH_CLIENT_ID: test-twitch-client-id + TWITCH_CLIENT_SECRET: test-twitch-client-secret + TWITCH_BOT_USER_ID: 123456789 + TWITCH_CHANNEL_USER_ID: 987654321 + ENCRYPTION_SECRET_KEY: test-encryption-secret-key-32-bytes-long! + GOOGLE_CLIENT_ID: test-google-client-id + GOOGLE_CLIENT_SECRET: test-google-client-secret + DISCORD_TOKEN: test-discord-token + DISCORD_BOT_DISABLED: true + COOKIE_KEY: test-cookie-key-32-bytes-long!!!! + +jobs: + playwright: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: coreyja_test + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: | + thread-frontend/package-lock.json + e2e/package-lock.json + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Install system dependencies + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: protobuf-compiler libasound2-dev + version: v0 + + - name: Install Tailwind CSS + run: | + curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 && \ + chmod +x tailwindcss-linux-x64 && \ + mv tailwindcss-linux-x64 tailwindcss && \ + ./tailwindcss -i server/src/styles/tailwind.css -o target/tailwind.css + + - name: Install frontend dependencies + run: | + cd thread-frontend + npm ci + + - name: Build frontend + run: ./scripts/build-frontend.sh + + - name: Run database migrations + run: | + cargo install sqlx-cli --no-default-features --features postgres || true + sqlx migrate run --source db/migrations + + - name: Build server (debug mode for speed) + run: cargo build --bin server + + - name: Install Playwright dependencies + run: | + cd e2e + npm ci + + - name: Install Playwright browsers + run: | + cd e2e + npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: | + cd e2e + npx playwright test + env: + CI: true + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 30 + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: e2e/test-results/ + retention-days: 30 \ No newline at end of file diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..aad68825 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +playwright-report/ +test-results/ +.DS_Store +*.log \ No newline at end of file diff --git a/e2e/auth/github-oauth-mock.ts b/e2e/auth/github-oauth-mock.ts new file mode 100644 index 00000000..5db9e810 --- /dev/null +++ b/e2e/auth/github-oauth-mock.ts @@ -0,0 +1,51 @@ +import { Page } from '@playwright/test'; + +export interface MockUser { + login: string; + node_id: string; + id: number; + email: string; + name: string; +} + +export async function setupGitHubOAuthMock(page: Page, user: MockUser) { + // Intercept GitHub OAuth authorization + await page.route('https://github.com/login/oauth/authorize**', async route => { + const url = new URL(route.request().url()); + const redirectUri = url.searchParams.get('redirect_uri'); + const state = url.searchParams.get('state'); + + // Immediately redirect back with mock code + await route.fulfill({ + status: 302, + headers: { + 'Location': `${redirectUri}?code=mock_code_${Date.now()}&state=${state}` + } + }); + }); + + // Mock token exchange + await page.route('https://github.com/login/oauth/access_token', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + access_token: `mock_token_${Date.now()}`, + expires_in: 28800, + refresh_token: `mock_refresh_${Date.now()}`, + refresh_token_expires_in: 15897600, + scope: 'read:user user:email', + token_type: 'bearer' + }) + }); + }); + + // Mock user API + await page.route('https://api.github.com/user', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(user) + }); + }); +} \ No newline at end of file diff --git a/e2e/fixtures/test-users.ts b/e2e/fixtures/test-users.ts new file mode 100644 index 00000000..5b9a27c3 --- /dev/null +++ b/e2e/fixtures/test-users.ts @@ -0,0 +1,16 @@ +export const testUsers = { + admin: { + login: 'test_admin', + node_id: 'MDQ6VXNlcjE=', + id: 1, + email: 'admin@test.com', + name: 'Test Admin' + }, + regular: { + login: 'test_user', + node_id: 'MDQ6VXNlcjI=', + id: 2, + email: 'user@test.com', + name: 'Test User' + } +}; \ No newline at end of file diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..d9ca7a59 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.54.2", + "@types/node": "^24.2.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", + "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", + "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..475c7919 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,23 @@ +{ + "name": "e2e", + "version": "1.0.0", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test --debug", + "test:headed": "playwright test --headed", + "test:report": "playwright show-report" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.54.2", + "@types/node": "^24.2.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..5673a06e --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 30000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: process.env.CI + ? 'cd .. && ./target/debug/server' + : 'cd .. && ./scripts/dev-build.sh && ./target/debug/server', + port: 3000, + reuseExistingServer: !process.env.CI, + timeout: 120000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); \ No newline at end of file diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts new file mode 100644 index 00000000..c86563a1 --- /dev/null +++ b/e2e/tests/smoke.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Smoke Tests', () => { + test('should load the homepage', async ({ page }) => { + // Navigate to homepage + await page.goto('/'); + + // Check that the page loads successfully + await expect(page).toHaveTitle(/coreyja/i); + + // Verify some basic content is present + const response = page.waitForResponse(resp => resp.status() === 200); + await page.reload(); + await response; + }); + + test('should navigate to admin login', async ({ page }) => { + // Navigate to admin login + await page.goto('/admin'); + + // Should redirect to login or show login page + await expect(page.url()).toMatch(/\/admin|\/login/); + }); +}); \ No newline at end of file diff --git a/server/src/al/tools/discord.rs b/server/src/al/tools/discord.rs index 927d9c3a..06b13ad5 100644 --- a/server/src/al/tools/discord.rs +++ b/server/src/al/tools/discord.rs @@ -74,9 +74,15 @@ impl Tool for SendDiscordMessage { let create_message = CreateMessage::new().content(message.build()); let discord_channel_id = ChannelId::new(input.channel_id); + // Check if Discord is configured + let discord = app_state + .discord + .as_ref() + .ok_or_else(|| cja::color_eyre::eyre::eyre!("Discord not configured"))?; + // Send the message discord_channel_id - .send_message(&app_state.discord, create_message) + .send_message(discord, create_message) .await .map_err(|e| cja::color_eyre::eyre::eyre!("Failed to send Discord message: {}", e))?; @@ -170,9 +176,15 @@ impl Tool for SendDiscordThreadMessage { create_message = create_message.reference_message((channel_id, message_id)); } + // Check if Discord is configured + let discord = app_state + .discord + .as_ref() + .ok_or_else(|| cja::color_eyre::eyre::eyre!("Discord not configured"))?; + // Send the message let sent_message = channel_id - .send_message(&app_state.discord, create_message) + .send_message(discord, create_message) .await .map_err(|e| cja::color_eyre::eyre::eyre!("Failed to send Discord message: {}", e))?; @@ -369,9 +381,14 @@ impl Tool for ReactToMessage { serenity::all::ReactionType::Unicode(input.emoji.clone()) }; - // Add the reaction - app_state + // Check if Discord is configured + let discord = app_state .discord + .as_ref() + .ok_or_else(|| cja::color_eyre::eyre::eyre!("Discord not configured"))?; + + // Add the reaction + discord .http .create_reaction(channel_id, message_id, &reaction_type) .await @@ -457,15 +474,21 @@ impl Tool for ListServerEmojis { .map_err(|_| cja::color_eyre::eyre::eyre!("Invalid guild ID"))?, ); + // Check if Discord is configured + let discord = app_state + .discord + .as_ref() + .ok_or_else(|| cja::color_eyre::eyre::eyre!("Discord not configured"))?; + // Get the guild let guild = guild_id - .to_partial_guild(&app_state.discord.http) + .to_partial_guild(&discord.http) .await .map_err(|e| cja::color_eyre::eyre::eyre!("Failed to get guild: {}", e))?; // Get emojis from the guild let emojis = guild - .emojis(&app_state.discord.http) + .emojis(&discord.http) .await .map_err(|e| cja::color_eyre::eyre::eyre!("Failed to get emojis: {}", e))?; diff --git a/server/src/discord.rs b/server/src/discord.rs index ce9ee3e3..8c09a05c 100644 --- a/server/src/discord.rs +++ b/server/src/discord.rs @@ -66,8 +66,11 @@ pub(crate) struct DiscordSetup { pub app_state_holder: Arc>>, } -pub(crate) async fn setup() -> cja::Result { - let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); +pub(crate) async fn setup() -> cja::Result> { + let Ok(token) = std::env::var("DISCORD_TOKEN") else { + tracing::info!("DISCORD_TOKEN not set, skipping Discord setup"); + return Ok(None); + }; let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; @@ -102,11 +105,11 @@ pub(crate) async fn setup() -> cja::Result { cache: client.cache.clone(), }; - Ok(DiscordSetup { + Ok(Some(DiscordSetup { bot: DiscordBot(client), client: outside_client, app_state_holder, - }) + })) } async fn event_handler( diff --git a/server/src/github/sponsors.rs b/server/src/github/sponsors.rs index 98a0b2c3..f00d0f83 100644 --- a/server/src/github/sponsors.rs +++ b/server/src/github/sponsors.rs @@ -305,7 +305,7 @@ mod tests { async fn test_insert_sponsors_upsert_behavior(pool: PgPool) { let sponsor = create_test_sponsor("gh_user_1", "testuser1", true); - let result = insert_sponsors(&[sponsor.clone()], &pool).await; + let result = insert_sponsors(std::slice::from_ref(&sponsor), &pool).await; assert!(result.is_ok()); let mut updated_sponsor = sponsor; diff --git a/server/src/http_server/cmd.rs b/server/src/http_server/cmd.rs index 09783382..aa41550f 100644 --- a/server/src/http_server/cmd.rs +++ b/server/src/http_server/cmd.rs @@ -111,9 +111,12 @@ async fn pageview_middleware( pub(crate) async fn serve() -> Result<()> { let discord = crate::discord::setup().await?; - let app_state = AppState::from_env(discord.client.clone()).await?; + let discord_client = discord.as_ref().map(|d| d.client.clone()); + let app_state = AppState::from_env(discord_client).await?; - *discord.app_state_holder.lock().unwrap() = Some(app_state.clone()); + if let Some(ref discord_setup) = discord { + *discord_setup.app_state_holder.lock().unwrap() = Some(app_state.clone()); + } let job_registry = Jobs; let syntax_css = syntect::html::css_for_theme_with_class_style( @@ -149,11 +152,16 @@ pub(crate) async fn serve() -> Result<()> { info!("Cron Disabled"); } - if std::env::var("DISCORD_BOT_DISABLED").unwrap_or_else(|_| "false".to_string()) == "false" { - info!("Discord Bot Enabled"); - futures.push(tokio::spawn(discord.bot.run())); + if let Some(discord) = discord { + if std::env::var("DISCORD_BOT_DISABLED").unwrap_or_else(|_| "false".to_string()) == "false" + { + info!("Discord Bot Enabled"); + futures.push(tokio::spawn(discord.bot.run())); + } else { + info!("Discord Bot Disabled"); + } } else { - info!("Discord Bot Disabled"); + info!("Discord not configured (DISCORD_TOKEN not set)"); } info!("Tasks Spawned"); diff --git a/server/src/http_server/test_helpers.rs b/server/src/http_server/test_helpers.rs index ed29ea0b..1c8e59c3 100644 --- a/server/src/http_server/test_helpers.rs +++ b/server/src/http_server/test_helpers.rs @@ -22,6 +22,7 @@ pub async fn create_test_app(pool: PgPool) -> Router { std::env::set_var("ENCRYPT_KEY", "test-encrypt-key-32-bytes-long!!"); let discord_setup = crate::discord::setup().await.unwrap(); + let discord_client = discord_setup.map(|d| d.client); // Create a minimal test state let state = AppState { @@ -43,7 +44,7 @@ pub async fn create_test_app(pool: PgPool) -> Router { cookie_key: cja::server::cookies::CookieKey::from_env_or_generate().unwrap(), encrypt_config: crate::encrypt::Config::from_env().unwrap(), posthog_key: None, - discord: discord_setup.client, + discord: discord_client, }; let syntax_css = String::new(); // Empty for tests diff --git a/server/src/jobs/bytes_discord_posts.rs b/server/src/jobs/bytes_discord_posts.rs index a78e095a..2e6279a8 100644 --- a/server/src/jobs/bytes_discord_posts.rs +++ b/server/src/jobs/bytes_discord_posts.rs @@ -101,13 +101,16 @@ impl Job for PostByteSubmission { .fetch_all(&app_state.db) .await?; + let Some(ref discord) = app_state.discord else { + tracing::info!("Discord not configured, skipping Discord post"); + return Ok(()); + }; + for channel in channels { let create_message = serenity::all::CreateMessage::new().content(&overall_msg); let channel_id = ChannelId::new(channel.channel_id.parse::()?); - channel_id - .send_message(&app_state.discord, create_message) - .await?; + channel_id.send_message(discord, create_message).await?; } Ok(()) diff --git a/server/src/jobs/discord_message_processor.rs b/server/src/jobs/discord_message_processor.rs index 7af13f7c..6a53c63c 100644 --- a/server/src/jobs/discord_message_processor.rs +++ b/server/src/jobs/discord_message_processor.rs @@ -32,9 +32,14 @@ impl JobTrait for ProcessDiscordMessage { return Ok(()); } + let Some(ref discord) = app_state.discord else { + tracing::info!("Discord not configured, skipping message processing"); + return Ok(()); + }; + // Get the channel to check its type - let channel = msg.channel_id.to_channel(&app_state.discord).await?; - let bot_user = app_state.discord.cache.current_user().clone(); + let channel = msg.channel_id.to_channel(discord).await?; + let bot_user = discord.cache.current_user().clone(); // Check if this is in a thread if let Some(guild_channel) = channel.guild() { @@ -75,7 +80,11 @@ impl ProcessDiscordMessage { let thread = if let Some(discord_meta) = existing_discord { self.get_existing_thread(db, discord_meta.thread_id).await? - } else if Self::is_bot_mentioned(&self.message, &app_state.discord.cache.current_user()) { + } else if app_state + .discord + .as_ref() + .is_some_and(|d| Self::is_bot_mentioned(&self.message, &d.cache.current_user())) + { self.create_new_thread_from_discord(db, &thread_id, guild_channel) .await? } else { @@ -175,7 +184,7 @@ impl ProcessDiscordMessage { .auto_archive_duration(serenity::AutoArchiveDuration::OneDay); guild_channel - .create_thread_from_message(&app_state.discord.http, msg.id, builder) + .create_thread_from_message(&app_state.discord.as_ref().unwrap().http, msg.id, builder) .await .map_err(Into::into) } diff --git a/server/src/jobs/refresh_discord.rs b/server/src/jobs/refresh_discord.rs index 8e9d77c0..17576f86 100644 --- a/server/src/jobs/refresh_discord.rs +++ b/server/src/jobs/refresh_discord.rs @@ -13,6 +13,11 @@ impl Job for RefreshDiscordChannels { const NAME: &'static str = "RefreshDiscordChannels"; async fn run(&self, state: AppState) -> cja::Result<()> { + let Some(ref discord) = state.discord else { + tracing::info!("Discord not configured, skipping channel refresh"); + return Ok(()); + }; + let channels = sqlx::query_as!(DiscordChannel, "SELECT * FROM DiscordChannels") .fetch_all(state.db()) .await?; @@ -22,7 +27,7 @@ impl Job for RefreshDiscordChannels { let channel_id = ChannelId::new(channel.channel_id.parse::()?); - let channel = state.discord.http.get_channel(channel_id).await?; + let channel = discord.http.get_channel(channel_id).await?; let serenity::all::Channel::Guild(channel) = channel else { tracing::error!("Channel is a DM Channel"); diff --git a/server/src/state.rs b/server/src/state.rs index 14a75d50..a3933fe1 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -107,12 +107,12 @@ pub(crate) struct AppState { pub cookie_key: CookieKey, pub encrypt_config: encrypt::Config, pub posthog_key: Option, - pub discord: DiscordClient, + pub discord: Option, } impl AppState { #[instrument(name = "AppState::from_env", err, skip(discord))] - pub async fn from_env(discord: DiscordClient) -> cja::Result { + pub async fn from_env(discord: Option) -> cja::Result { let blog_posts = BlogPosts::from_static_dir()?; let blog_posts = Arc::new(blog_posts);