/** * LiveComponents E2E Tests - Chunked Upload with Progress * * Tests for large file upload with chunking and progress tracking: * - File chunking into smaller pieces * - Sequential chunk upload with retry * - Real-time progress updates * - Pause/resume functionality * - Error handling and recovery * - Upload cancellation * * @see src/Framework/LiveComponents/Services/ChunkedUploadHandler.php * @see resources/js/modules/LiveComponent.js (chunked upload) */ import { test, expect } from '@playwright/test'; import path from 'path'; test.describe('LiveComponents - Chunked Upload', () => { test.beforeEach(async ({ page }) => { // Navigate to upload test page await page.goto('/livecomponents/upload-test'); // Wait for LiveComponent to be initialized await page.waitForFunction(() => window.LiveComponent !== undefined); }); test('should chunk large file into multiple pieces', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent/upload')) { requests.push(request); } }); // Create test file (5MB - should be chunked) const testFile = await page.evaluate(() => { const size = 5 * 1024 * 1024; // 5MB const blob = new Blob([new ArrayBuffer(size)], { type: 'application/octet-stream' }); return new File([blob], 'test-file.bin', { type: 'application/octet-stream' }); }); // Set file input const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB }); // Wait for chunked upload to complete await page.waitForSelector('.upload-complete', { timeout: 30000 }); // With 1MB chunk size, should have ~5 chunks expect(requests.length).toBeGreaterThanOrEqual(5); expect(requests.length).toBeLessThanOrEqual(6); // Verify each chunk request has correct headers requests.forEach((request, index) => { const headers = request.headers(); expect(headers['x-chunk-index']).toBeDefined(); expect(headers['x-chunk-total']).toBeDefined(); expect(headers['x-upload-id']).toBeDefined(); }); }); test('should track upload progress in real-time', async ({ page }) => { const progressUpdates = []; // Monitor progress updates await page.exposeFunction('onProgressUpdate', (progress) => { progressUpdates.push(progress); }); await page.evaluate(() => { window.addEventListener('livecomponent:upload-progress', (e) => { window.onProgressUpdate(e.detail); }); }); // Upload file const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB }); // Wait for upload to complete await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Should have received multiple progress updates expect(progressUpdates.length).toBeGreaterThan(0); // Progress should increase monotonically for (let i = 1; i < progressUpdates.length; i++) { expect(progressUpdates[i].percentage).toBeGreaterThanOrEqual( progressUpdates[i - 1].percentage ); } // Final progress should be 100% const finalProgress = progressUpdates[progressUpdates.length - 1]; expect(finalProgress.percentage).toBe(100); }); test('should display progress bar during upload', async ({ page }) => { const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB }); // Progress bar should appear const progressBar = page.locator('.upload-progress-bar'); await expect(progressBar).toBeVisible(); // Progress percentage should be visible const progressText = page.locator('.upload-progress-text'); await expect(progressText).toBeVisible(); // Wait for progress to reach 100% await expect(progressText).toContainText('100%', { timeout: 30000 }); // Progress bar should hide or show completion await expect(progressBar).toHaveClass(/complete|success/); }); test('should handle pause and resume correctly', async ({ page }) => { const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB }); // Wait for upload to start await page.waitForSelector('.upload-progress-bar'); // Wait for some progress await page.waitForTimeout(500); // Pause upload await page.click('[data-action="pause-upload"]'); const progressTextBefore = await page.locator('.upload-progress-text').textContent(); const percentageBefore = parseInt(progressTextBefore); // Wait to ensure upload is paused await page.waitForTimeout(1000); const progressTextAfter = await page.locator('.upload-progress-text').textContent(); const percentageAfter = parseInt(progressTextAfter); // Progress should not have changed (paused) expect(percentageAfter).toBe(percentageBefore); // Resume upload await page.click('[data-action="resume-upload"]'); // Wait for upload to complete await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Final progress should be 100% const finalProgress = await page.locator('.upload-progress-text').textContent(); expect(finalProgress).toContain('100%'); }); test('should allow cancelling upload', async ({ page }) => { const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB }); // Wait for upload to start await page.waitForSelector('.upload-progress-bar'); // Wait for some progress await page.waitForTimeout(500); // Cancel upload await page.click('[data-action="cancel-upload"]'); // Progress bar should disappear or show cancelled state await expect(page.locator('.upload-progress-bar')).not.toBeVisible(); // Cancelled notification should appear const notification = page.locator('.upload-notification'); await expect(notification).toBeVisible(); await expect(notification).toContainText(/cancel/i); }); test('should retry failed chunks automatically', async ({ page }) => { let attemptCount = 0; // Mock server to fail first 2 chunk uploads await page.route('**/livecomponent/upload/**', async (route) => { attemptCount++; if (attemptCount <= 2) { // Fail first 2 attempts route.fulfill({ status: 500, body: JSON.stringify({ error: 'Temporary server error' }) }); } else { // Succeed on retry route.continue(); } }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(1024 * 1024) // 1MB (single chunk) }); // Should eventually succeed after retries await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Should have attempted at least 3 times (2 failures + 1 success) expect(attemptCount).toBeGreaterThanOrEqual(3); }); test('should handle chunk upload failure with max retries', async ({ page }) => { // Mock server to always fail await page.route('**/livecomponent/upload/**', async (route) => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Permanent server error' }) }); }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(1024 * 1024) // 1MB }); // Should show error after max retries const errorNotification = page.locator('.upload-error'); await expect(errorNotification).toBeVisible({ timeout: 30000 }); await expect(errorNotification).toContainText(/failed|error/i); // Progress bar should show error state const progressBar = page.locator('.upload-progress-bar'); await expect(progressBar).toHaveClass(/error|failed/); }); test('should upload multiple files sequentially', async ({ page }) => { const fileInput = page.locator('input[type="file"][data-lc-upload][multiple]'); // Select multiple files await fileInput.setInputFiles([ { name: 'file1.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB }, { name: 'file2.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB }, { name: 'file3.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB } ]); // Should show multiple progress bars or list const uploadItems = page.locator('.upload-item'); await expect(uploadItems).toHaveCount(3); // Wait for all uploads to complete await page.waitForSelector('.all-uploads-complete', { timeout: 60000 }); // All items should show completion const completedItems = page.locator('.upload-item.complete'); await expect(completedItems).toHaveCount(3); }); test('should preserve chunk order during upload', async ({ page }) => { const chunkOrder = []; // Monitor chunk requests page.on('request', request => { if (request.url().includes('/livecomponent/upload')) { const headers = request.headers(); const chunkIndex = parseInt(headers['x-chunk-index']); chunkOrder.push(chunkIndex); } }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB }); await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Chunks should be uploaded in order (0, 1, 2, ...) for (let i = 0; i < chunkOrder.length; i++) { expect(chunkOrder[i]).toBe(i); } }); test('should include upload metadata in chunk requests', async ({ page }) => { const requests = []; page.on('request', request => { if (request.url().includes('/livecomponent/upload')) { requests.push(request); } }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'document.pdf', mimeType: 'application/pdf', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB }); await page.waitForSelector('.upload-complete', { timeout: 30000 }); // First chunk should have file metadata const firstRequest = requests[0]; const headers = firstRequest.headers(); expect(headers['x-file-name']).toBe('document.pdf'); expect(headers['x-file-size']).toBeDefined(); expect(headers['x-file-type']).toBe('application/pdf'); expect(headers['x-upload-id']).toBeDefined(); }); test('should show upload speed and time remaining', async ({ page }) => { const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB }); // Wait for upload to start await page.waitForSelector('.upload-progress-bar'); // Speed indicator should be visible const speedIndicator = page.locator('.upload-speed'); await expect(speedIndicator).toBeVisible(); await expect(speedIndicator).toContainText(/MB\/s|KB\/s/); // Time remaining should be visible const timeRemaining = page.locator('.upload-time-remaining'); await expect(timeRemaining).toBeVisible(); await expect(timeRemaining).toContainText(/second|minute/); }); test('should handle file size validation before upload', async ({ page }) => { // Mock large file exceeding limit (e.g., 100MB limit) const fileInput = page.locator('input[type="file"][data-lc-upload]'); // Set max file size via data attribute await page.evaluate(() => { const input = document.querySelector('input[type="file"][data-lc-upload]'); input.dataset.maxFileSize = '10485760'; // 10MB limit }); // Try to upload 20MB file await fileInput.setInputFiles({ name: 'large-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(20 * 1024 * 1024) // 20MB }); // Should show validation error const errorNotification = page.locator('.upload-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/too large|size limit/i); // Upload should not start const progressBar = page.locator('.upload-progress-bar'); await expect(progressBar).not.toBeVisible(); }); test('should handle file type validation before upload', async ({ page }) => { // Set allowed file types await page.evaluate(() => { const input = document.querySelector('input[type="file"][data-lc-upload]'); input.dataset.allowedTypes = 'image/jpeg,image/png,image/gif'; }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); // Try to upload disallowed file type await fileInput.setInputFiles({ name: 'document.pdf', mimeType: 'application/pdf', buffer: Buffer.alloc(1024 * 1024) // 1MB }); // Should show validation error const errorNotification = page.locator('.upload-error'); await expect(errorNotification).toBeVisible(); await expect(errorNotification).toContainText(/file type|not allowed/i); }); test('should emit upload events for monitoring', async ({ page }) => { const events = { start: null, progress: [], complete: null }; await page.exposeFunction('onUploadStart', (event) => { events.start = event; }); await page.exposeFunction('onUploadProgress', (event) => { events.progress.push(event); }); await page.exposeFunction('onUploadComplete', (event) => { events.complete = event; }); await page.evaluate(() => { window.addEventListener('livecomponent:upload-start', (e) => { window.onUploadStart(e.detail); }); window.addEventListener('livecomponent:upload-progress', (e) => { window.onUploadProgress(e.detail); }); window.addEventListener('livecomponent:upload-complete', (e) => { window.onUploadComplete(e.detail); }); }); // Upload file const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB }); await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Wait for events await page.waitForTimeout(500); // Verify start event expect(events.start).not.toBeNull(); expect(events.start).toHaveProperty('fileName'); expect(events.start).toHaveProperty('fileSize'); // Verify progress events expect(events.progress.length).toBeGreaterThan(0); events.progress.forEach(event => { expect(event).toHaveProperty('percentage'); expect(event).toHaveProperty('uploadedBytes'); }); // Verify complete event expect(events.complete).not.toBeNull(); expect(events.complete).toHaveProperty('uploadId'); expect(events.complete).toHaveProperty('duration'); }); test('should handle network interruption and resume', async ({ page }) => { let requestCount = 0; // Simulate network interruption await page.route('**/livecomponent/upload/**', async (route) => { requestCount++; if (requestCount === 2) { // Interrupt on 2nd chunk route.abort('failed'); } else { route.continue(); } }); const fileInput = page.locator('input[type="file"][data-lc-upload]'); await fileInput.setInputFiles({ name: 'test-file.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB (3 chunks) }); // Should retry and eventually complete await page.waitForSelector('.upload-complete', { timeout: 30000 }); // Should have attempted more than 3 times due to retry expect(requestCount).toBeGreaterThan(3); }); });