Files
michaelschiemer/tests/e2e/livecomponents-chunked-upload.spec.js
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

508 lines
18 KiB
JavaScript

/**
* 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);
});
});