87f245d3fc
- Sunset Glitch color palette applied to all templates - Font Awesome icons throughout UI - Download manager with parallel queue and progress tracking - Settings page with dynamic configuration - Recommendations router enhanced with scoring - Local vendor libs (Alpine.js, HTMX) for offline support - Auto test suite with screenshots - Series releases list component - New download model
372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
/**
|
|
* 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 => `- `).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);
|
|
})();
|