- 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.
508 lines
18 KiB
JavaScript
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);
|
|
});
|
|
});
|