test(e2e): add download flow tests and fix status CSS classes
- Add Playwright E2E tests covering real user download journeys: - Search anime → choose episodes → trigger download (with toast) - Real file download via static fixture and verify completion in UI - Click new release on homepage → switch to anime search tab - Search for series and display mocked results - Fix bug in downloads_list.html: CSS classes used task.status (enum) which rendered as 'status-DownloadStatus.COMPLETED' instead of 'status-completed'. Use task.status.value for correct CSS class names. - Add static test fixture (20KB fake MP4) for reliable download tests - All 16 E2E tests passing (12 existing + 4 new)
This commit is contained in:
Binary file not shown.
@@ -1,10 +1,10 @@
|
|||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="downloads-grid">
|
<div class="downloads-grid">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="download-item status-{{ task.status }}">
|
<div class="download-item status-{{ task.status.value }}">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
||||||
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
|
<span class="badge badge-{{ task.status.value }}">{{ task.status | upper }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { switchTab, waitForHtmx, collectJsErrors } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download Flow E2E Tests
|
||||||
|
*
|
||||||
|
* These tests cover the complete user journey for discovering and downloading
|
||||||
|
* anime/series content, including mocked provider flows and real file downloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getAuthToken(page: Page): Promise<string | null> {
|
||||||
|
return page.evaluate(() => localStorage.getItem('auth_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDownloadViaApi(page: Page, url: string): Promise<string> {
|
||||||
|
const token = await getAuthToken(page);
|
||||||
|
if (!token) throw new Error('No auth token found');
|
||||||
|
|
||||||
|
const response = await page.request.post(`/api/anime/download?url=${encodeURIComponent(url)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).toBeLessThan(400);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.task_id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDownloadViaApi(page: Page, taskId: string): Promise<void> {
|
||||||
|
const token = await getAuthToken(page);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
await page.request.delete(`/api/downloads/${taskId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Episode picker + download toast (fully mocked)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Download Flow E2E', () => {
|
||||||
|
test('should choose episodes from search result and trigger download toast', async ({ page }) => {
|
||||||
|
const jsErrors = collectJsErrors(page);
|
||||||
|
|
||||||
|
// 1. Mock search results with a full card including dropdown
|
||||||
|
await page.route('/api/anime/search?**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||||
|
<div class="sr-card" style="--sr-accent: #00d9ff;">
|
||||||
|
<a class="sr-poster-link" href="https://example.com/anime/frieren" target="_blank" rel="noopener">
|
||||||
|
<img class="sr-poster-img" src="https://placehold.co/240x360" alt="Frieren" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<div class="sr-body">
|
||||||
|
<div class="sr-top">
|
||||||
|
<h3 class="sr-title">Frieren: Beyond Journey's End</h3>
|
||||||
|
</div>
|
||||||
|
<div class="sr-actions">
|
||||||
|
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||||
|
<button class="sr-btn sr-btn-dl" @click.stop="openDropdown = (openDropdown === 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren') ? null : 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren'">
|
||||||
|
<i class="fas fa-download"></i> Telecharger
|
||||||
|
</button>
|
||||||
|
<div class="sr-dropdown-menu" x-show="openDropdown === 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren'" x-transition>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-post="/api/anime/download-season?url=https%3A%2F%2Fexample.com%2Fanime%2Ffrieren&lang=vostfr"
|
||||||
|
hx-swap="none">
|
||||||
|
<i class="fas fa-layer-group"></i> Saison complete
|
||||||
|
</button>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-get="/api/anime/episodes?url=https%3A%2F%2Fexample.com%2Fanime%2Ffrieren&lang=vostfr&html=1"
|
||||||
|
hx-target="#player-container"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Mock episode list pointing to local static file for real download
|
||||||
|
const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4';
|
||||||
|
await page.route('/api/anime/episodes?**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div class="episode-list-container section-container" x-data="{ view: 'grid' }">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 style="border: none; padding: 0; margin-bottom: 5px;">Frieren</h2>
|
||||||
|
<span class="badge">1 épisodes disponibles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="video-player-display"></div>
|
||||||
|
<div class="episodes-content view-grid" style="margin-top: 25px;">
|
||||||
|
<div class="episode-item">
|
||||||
|
<div class="ep-number">EP 1</div>
|
||||||
|
<div class="ep-title" title="Le départ">Le départ</div>
|
||||||
|
<div class="ep-actions">
|
||||||
|
<button class="btn btn-primary btn-small"
|
||||||
|
hx-get="/api/player/embed?url=${encodeURIComponent(testFileUrl)}"
|
||||||
|
hx-target="#video-player-display"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<i class="fas fa-play"></i> Regarder
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-icon btn-small"
|
||||||
|
hx-post="/api/anime/download?url=${encodeURIComponent(testFileUrl)}"
|
||||||
|
hx-swap="none"
|
||||||
|
title="Télécharger cet épisode">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/web');
|
||||||
|
await switchTab(page, 'Anime');
|
||||||
|
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// Trigger search
|
||||||
|
await page.fill('#animeSearchInput', 'Frieren');
|
||||||
|
await page.click('#tab-anime button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for search results
|
||||||
|
await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await expect(page.locator('#animeSearchResults')).toContainText("Frieren: Beyond Journey's End");
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await page.locator('#animeSearchResults .sr-card').first().locator('.sr-btn-dl').click();
|
||||||
|
await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// Click "Choisir des épisodes"
|
||||||
|
await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').click();
|
||||||
|
|
||||||
|
// Wait for episode list
|
||||||
|
await page.locator('#player-container .episode-item').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await expect(page.locator('#player-container')).toContainText('EP 1');
|
||||||
|
|
||||||
|
// Click download on first episode and wait for the real server response
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/anime/download') && resp.request().method() === 'POST'
|
||||||
|
),
|
||||||
|
page.locator('#player-container .episode-item').first()
|
||||||
|
.locator('button[title="Télécharger cet épisode"]').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status()).toBeLessThan(400);
|
||||||
|
|
||||||
|
// Wait for toast triggered by HX-Trigger header
|
||||||
|
await page.locator('#toast-container .toast-success')
|
||||||
|
.filter({ hasText: /Téléchargement lancé/i })
|
||||||
|
.waitFor({ state: 'visible', timeout: 8000 });
|
||||||
|
|
||||||
|
// Cleanup the created download task via API
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.task_id) {
|
||||||
|
await deleteDownloadViaApi(page, body.task_id as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(jsErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Real file download via static fixture
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should download a real file and show it in downloads list', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
const jsErrors = collectJsErrors(page);
|
||||||
|
|
||||||
|
// Navigate first so localStorage is available on the correct origin
|
||||||
|
await page.goto('/web');
|
||||||
|
|
||||||
|
// Use the static test file served by the app itself
|
||||||
|
const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4';
|
||||||
|
|
||||||
|
// 1. Create download via API
|
||||||
|
const taskId = await createDownloadViaApi(page, testFileUrl);
|
||||||
|
|
||||||
|
// 2. Navigate to downloads tab
|
||||||
|
await switchTab(page, 'Téléchargements');
|
||||||
|
await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// 3. Wait for the task to appear in the list
|
||||||
|
await page.locator('#downloads-container-inner .download-item').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
|
// 4. Wait for completion (poll until status is completed)
|
||||||
|
await expect(page.locator('#downloads-container-inner .download-item.status-completed')).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// 5. Verify progress is 100%
|
||||||
|
const progressText = await page.locator('#downloads-container-inner .download-item.status-completed .download-meta span').first().textContent();
|
||||||
|
expect(progressText).toContain('100');
|
||||||
|
|
||||||
|
// 6. Verify filename is shown
|
||||||
|
await expect(page.locator('#downloads-container-inner .download-item .download-name')).toContainText('test_episode_01.mp4');
|
||||||
|
|
||||||
|
// 7. Verify completed actions are present (stream + download links)
|
||||||
|
await expect(page.locator('#downloads-container-inner .download-item.status-completed a[title="Streamer"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#downloads-container-inner .download-item.status-completed a[download]')).toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await deleteDownloadViaApi(page, taskId);
|
||||||
|
|
||||||
|
expect(jsErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Click a new release on homepage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should click a new release and switch to anime search', async ({ page }) => {
|
||||||
|
const jsErrors = collectJsErrors(page);
|
||||||
|
|
||||||
|
// Mock releases with a single anime card
|
||||||
|
await page.route('/api/releases/latest', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div class="hc" id="anime-abc123"
|
||||||
|
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = 'Spy x Family'; htmx.trigger(input, 'keyup'); } });"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="hc-poster">
|
||||||
|
<img src="https://placehold.co/400x600" alt="Spy x Family" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="hc-info">
|
||||||
|
<span class="hc-src">Anime-Sama</span>
|
||||||
|
<span class="hc-title">Spy x Family</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock empty recommendations so they don't interfere
|
||||||
|
await page.route('/api/recommendations', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'text/html', body: '<p></p>' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/web');
|
||||||
|
|
||||||
|
// Wait for releases to load
|
||||||
|
await page.locator('#releasesList .hc').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await expect(page.locator('#releasesList .hc-title')).toContainText('Spy x Family');
|
||||||
|
|
||||||
|
// Click the release card
|
||||||
|
await page.locator('#releasesList .hc').first().click();
|
||||||
|
|
||||||
|
// Should switch to anime tab and populate search input
|
||||||
|
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await expect(page.locator('#animeSearchInput')).toHaveValue('Spy x Family');
|
||||||
|
|
||||||
|
expect(jsErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Series search flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should search for series and display results', async ({ page }) => {
|
||||||
|
const jsErrors = collectJsErrors(page);
|
||||||
|
|
||||||
|
await page.route('/api/series/search?**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div class="sr-list">
|
||||||
|
<div class="sr-card">
|
||||||
|
<div class="sr-body">
|
||||||
|
<div class="sr-top">
|
||||||
|
<h3 class="sr-title">Breaking Bad</h3>
|
||||||
|
</div>
|
||||||
|
<p class="sr-synopsis">A high school chemistry teacher turned methamphetamine producer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sr-card">
|
||||||
|
<div class="sr-body">
|
||||||
|
<div class="sr-top">
|
||||||
|
<h3 class="sr-title">Better Call Saul</h3>
|
||||||
|
</div>
|
||||||
|
<p class="sr-synopsis">The trials and tribulations of criminal lawyer Jimmy McGill.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/web');
|
||||||
|
await switchTab(page, 'Série');
|
||||||
|
await page.locator('#tab-series').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
await page.fill('#seriesSearchInput', 'Breaking');
|
||||||
|
await page.click('#tab-series button[type="submit"]');
|
||||||
|
|
||||||
|
await page.locator('#seriesSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await expect(page.locator('#seriesSearchResults')).toContainText('Breaking Bad');
|
||||||
|
await expect(page.locator('#seriesSearchResults')).toContainText('Better Call Saul');
|
||||||
|
|
||||||
|
expect(jsErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user