/** * Ohm Streaming - Automated E2E Test Suite * Run: node tests/auto/run_tests.mjs * Output: tests/auto/results/report.md + screenshots/ */ import { chromium } from 'playwright'; import fs from 'fs'; import path from 'path'; const BASE = 'http://127.0.0.1:3000'; const RESULTS_DIR = path.join(import.meta.dirname, 'results'); const SCREENSHOT_DIR = path.join(RESULTS_DIR, 'screenshots'); const CREDS = { username: 'roman', password: 'roman123' }; // ── Helpers ── const results = { passed: 0, failed: 0, errors: [], duration: 0 }; const startTime = Date.now(); function screenshot(page, name) { const p = path.join(SCREENSHOT_DIR, `${name}.png`); return page.screenshot({ path: p, fullPage: true }).then(() => p); } async function test(name, fn) { try { await fn(); results.passed++; console.log(` ✅ ${name}`); } catch (err) { results.failed++; const msg = `❌ ${name}: ${err.message}`; results.errors.push(msg); console.error(` ❌ ${name}: ${err.message}`); } } function assert(condition, message) { if (!condition) throw new Error(message || 'Assertion failed'); } // ── Main ── (async () => { // Ensure output dirs fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); // Collect console errors const consoleErrors = []; page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); // Network error tracking const networkErrors = []; page.on('requestfailed', req => { networkErrors.push(`${req.method()} ${req.url()}: ${req.failure()?.errorText}`); }); console.log('\n🧪 Ohm Streaming - Automated Test Suite\n'); console.log('═══ Phase 1: API Health ═══'); // ── Phase 1: API Health Checks ── await page.goto(`${BASE}/health`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await page.waitForTimeout(1000); await test('GET /health returns 200', async () => { const text = await page.textContent('body'); const json = JSON.parse(text); assert(json.status === 'healthy' || json.status === 'ok', `Unexpected status: ${json.status}`); }); await test('GET / returns landing page', async () => { const resp = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 10000 }); assert(resp.status() === 200, `Status ${resp.status()}`); await page.waitForTimeout(1500); const screenshotPath = await screenshot(page, '01_landing_page'); console.log(` 📸 ${screenshotPath}`); }); await test('GET /login returns login page', async () => { const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 }); assert(resp.status() === 200); await page.waitForTimeout(1500); const screenshotPath = await screenshot(page, '02_login_page'); console.log(` 📸 ${screenshotPath}`); }); // ── Phase 2: Authentication ── console.log('\n═══ Phase 2: Authentication ═══'); await test('Login with valid credentials (roman/roman123)', async () => { await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await page.waitForTimeout(2000); // Use API to login (SPA approach) await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await page.waitForTimeout(2000); const token = await page.evaluate(async (creds) => { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(creds) }); if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`); return (await res.json()).access_token; }, CREDS); assert(token && token.length > 10, 'No valid token received'); // Inject token into localStorage await page.evaluate((t) => { localStorage.setItem('auth_token', t); }, token); console.log(` 🔑 Token received (${token.substring(0, 20)}...)`); }); await test('GET /api/auth/me returns user info', async () => { const user = await page.evaluate(async () => { const token = localStorage.getItem('auth_token'); const res = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }); return res.json(); }); // Response may be { username, ... } or { user: { username, ... } } const name = user.username || user.user?.username || user.id; assert(name, `No username found in /me response: ${JSON.stringify(user).substring(0, 200)}`); console.log(` 👤 User: ${name} (admin: ${user.is_admin || user.user?.is_admin || false})`); }); // ── Phase 3: SPA Navigation ── console.log('\n═══ Phase 3: SPA Navigation (/web) ═══'); const tabs = ['home', 'anime', 'series', 'providers', 'downloads', 'watchlist', 'settings']; for (const tab of tabs) { await test(`Navigate to tab: ${tab}`, async () => { await page.goto(`${BASE}/web`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await page.waitForTimeout(2000); // Inject auth await page.evaluate(() => { // Token should already be in localStorage from login test // but let's verify const token = localStorage.getItem('auth_token'); if (!token) throw new Error('No auth token in localStorage'); }); // Switch tab using the app's own mechanism await page.evaluate((tabName) => { window.location.hash = tabName; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } })); }, tab); await page.waitForTimeout(3000); // Check no JS errors during navigation const currentErrors = consoleErrors.length; // Just verify page didn't crash const content = await page.textContent('body'); assert(content && content.length > 10, `Tab ${tab} rendered empty content`); const screenshotPath = await screenshot(page, `03_tab_${tab}`); console.log(` 📸 ${screenshotPath}`); }); } // ── Phase 4: API Endpoints ── console.log('\n═══ Phase 4: API Endpoints ═══'); const apiTests = [ { name: 'GET /api/settings', endpoint: '/api/settings', method: 'GET' }, { name: 'GET /api/favorites', endpoint: '/api/favorites', method: 'GET' }, { name: 'GET /api/watchlist', endpoint: '/api/watchlist', method: 'GET' }, { name: 'GET /api/downloads', endpoint: '/api/downloads', method: 'GET' }, { name: 'GET /api/watchlist/settings', endpoint: '/api/watchlist/settings', method: 'GET' }, { name: 'GET /api/watchlist/stats/summary', endpoint: '/api/watchlist/stats/summary', method: 'GET' }, { name: 'GET /api/providers/health', endpoint: '/api/providers/health', method: 'GET' }, { name: 'GET /api/recommendations', endpoint: '/api/recommendations', method: 'GET' }, { name: 'GET /api/releases/latest', endpoint: '/api/releases/latest', method: 'GET' }, { name: 'GET /api/favorites/stats', endpoint: '/api/favorites/stats', method: 'GET' }, ]; for (const apiTest of apiTests) { await test(`${apiTest.name} returns 200`, async () => { const result = await page.evaluate(async ({ endpoint, method }) => { const token = localStorage.getItem('auth_token'); const res = await fetch(endpoint, { method, headers: { 'Authorization': `Bearer ${token}` } }); let body = null; try { body = await res.json(); } catch(e) { /* body stays null */ } return { status: res.status, body }; }, apiTest); assert(result.status === 200, `${apiTest.name} returned ${result.status}: ${JSON.stringify(result.body).substring(0, 200)}`); // Verify it's valid JSON assert(typeof result.body === 'object', `${apiTest.name} returned non-JSON`); }); } // ── Phase 5: Content Validation ── console.log('\n═══ Phase 5: Content Validation ═══'); await test('Home tab renders content (not blank)', async () => { await page.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await page.waitForTimeout(3000); const content = await page.textContent('body'); assert(content.length > 100, 'Home tab content too short - may be blank'); console.log(` 📝 Content length: ${content.length} chars`); }); await test('Alpine.js loaded correctly', async () => { const alpineLoaded = await page.evaluate(() => typeof window.Alpine !== 'undefined'); assert(alpineLoaded, 'Alpine.js not loaded - x-* directives are dead'); console.log(` ⚡ Alpine.js: loaded`); }); await test('HTMX loaded correctly', async () => { const htmxLoaded = await page.evaluate(() => typeof window.htmx !== 'undefined'); assert(htmxLoaded, 'HTMX not loaded'); console.log(` ⚡ HTMX: loaded`); }); await test('No critical JS errors in console', async () => { // Filter out non-critical errors (network, extensions) const critical = consoleErrors.filter(e => !e.includes('favicon') && !e.includes('net::ERR_CONNECTION') && !e.includes('404') && !e.includes('DevTools') ); assert(critical.length === 0, `${critical.length} critical JS errors: ${critical.slice(0, 3).join('; ')}`); console.log(` ✨ Console clean (${consoleErrors.length} total, 0 critical)`); }); // ── Phase 6: Search Functionality ── console.log('\n═══ Phase 6: Search Functionality ═══'); await test('Anime search API works', async () => { const result = await page.evaluate(async () => { const token = localStorage.getItem('auth_token'); const res = await fetch('/api/anime/search?q=naruto&limit=3', { headers: { 'Authorization': `Bearer ${token}` } }); return { status: res.status, body: await res.json() }; }); // Search may return empty if providers are down, but should not error assert(result.status === 200, `Search returned ${result.status}`); console.log(` 🔍 Search results: ${JSON.stringify(result.body).substring(0, 100)}`); }); // ── Phase 7: Responsive Design ── console.log('\n═══ Phase 7: Responsive Design ═══'); await test('Mobile viewport rendering', async () => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15' }); const mobilePage = await context.newPage(); // Re-auth on mobile await mobilePage.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await mobilePage.waitForTimeout(2000); const token = await mobilePage.evaluate(async (creds) => { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(creds) }); return (await res.json()).access_token; }, CREDS); await mobilePage.evaluate((t) => localStorage.setItem('auth_token', t), token); await mobilePage.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await mobilePage.waitForTimeout(3000); const screenshotPath = await mobilePage.screenshot({ path: path.join(SCREENSHOT_DIR, '07_mobile_home.png'), fullPage: true }); console.log(` 📸 ${screenshotPath}`); // Check for horizontal overflow const overflow = await mobilePage.evaluate(() => { const w = window.innerWidth; return Array.from(document.querySelectorAll('*')) .filter(el => el.getBoundingClientRect().width > w) .length; }); assert(overflow === 0, `${overflow} elements overflow horizontally on mobile`); await context.close(); console.log(` 📱 Mobile: no horizontal overflow`); }); // ── Phase 8: Settings API ── console.log('\n═══ Phase 8: Settings & Providers ═══'); await test('GET /api/settings returns valid config', async () => { const settings = await page.evaluate(async () => { const token = localStorage.getItem('auth_token'); const res = await fetch('/api/settings', { headers: { 'Authorization': `Bearer ${token}` } }); return res.json(); }); assert(settings && typeof settings === 'object', 'Settings not an object'); console.log(` ⚙️ Settings keys: ${Object.keys(settings).join(', ')}`); }); await test('GET /api/providers/health check', async () => { const health = await page.evaluate(async () => { const token = localStorage.getItem('auth_token'); const res = await fetch('/api/providers/health', { headers: { 'Authorization': `Bearer ${token}` } }); return res.json(); }); assert(health !== null, 'Provider health returned null'); const providerCount = Array.isArray(health) ? health.length : Object.keys(health).length; console.log(` 🏥 Providers checked: ${providerCount}`); }); await browser.close(); // ── Generate Report ── results.duration = ((Date.now() - startTime) / 1000).toFixed(1); consoleErrors.length = 0; const report = `# Ohm Streaming - Automated Test Report **Date:** ${new Date().toISOString()} **Duration:** ${results.duration}s **Base URL:** ${BASE} ## Summary | Metric | Value | |--------|-------| | ✅ Passed | ${results.passed} | | ❌ Failed | ${results.failed} | | 📊 Total | ${results.passed + results.failed} | | 📊 Pass Rate | ${((results.passed / (results.passed + results.failed)) * 100).toFixed(1)}% | ${results.errors.length > 0 ? `## Failed Tests\n\n${results.errors.map((e, i) => `${i + 1}. ${e}`).join('\n')}` : '## All tests passed!'} ## Screenshots ${fs.readdirSync(SCREENSHOT_DIR).map(f => `- ![](screenshots/${f})`).join('\n')} `; fs.writeFileSync(path.join(RESULTS_DIR, 'report.md'), report); console.log('\n═══════════════════════════════════'); console.log(` Results: ${results.passed}/${results.passed + results.failed} passed (${results.duration}s)`); console.log(` Report: ${path.join(RESULTS_DIR, 'report.md')}`); console.log(` Screenshots: ${SCREENSHOT_DIR}`); if (results.errors.length > 0) { console.log(`\n Failed tests:`); results.errors.forEach(e => console.log(` ${e}`)); } console.log('═══════════════════════════════════\n'); process.exit(results.failed > 0 ? 1 : 0); })();