/** * Mobile UX Tests - Mobile-Specific User Experience Testing * Tests touch interactions, rotation handling, camera switching, * torch functionality, and permission flows for mobile devices */ import { test, expect } from '@playwright/test'; test.describe('Mobile Touch Interactions', () => { const testEventId = 'evt-001'; test.beforeEach(async ({ page, context }) => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE await context.grantPermissions(['camera']); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should handle touch interactions on all buttons', async ({ page }) => { // Test settings button touch const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await expect(settingsButton).toBeVisible(); // Touch target should be large enough (minimum 44px) const buttonBox = await settingsButton.boundingBox(); expect(buttonBox?.width).toBeGreaterThanOrEqual(44); expect(buttonBox?.height).toBeGreaterThanOrEqual(44); // Touch interaction should work await settingsButton.tap(); await expect(page.locator('text=Gate/Zone')).toBeVisible(); // Close settings with touch await settingsButton.tap(); await expect(page.locator('text=Gate/Zone')).not.toBeVisible(); // Test torch button if present const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)'); if (await torchButton.isVisible()) { const torchBox = await torchButton.boundingBox(); expect(torchBox?.width).toBeGreaterThanOrEqual(44); expect(torchBox?.height).toBeGreaterThanOrEqual(44); await torchButton.tap(); // Should handle torch toggle without errors } }); test('should provide tactile feedback for scan results', async ({ page }) => { // Test vibration API integration const vibrationSupported = await page.evaluate(() => 'vibrate' in navigator); if (vibrationSupported) { // Simulate successful scan with vibration feedback await page.evaluate(() => { // Test vibration pattern for success navigator.vibrate([50, 30, 50]); // Short-medium-short pattern window.dispatchEvent(new CustomEvent('mock-scan-result', { detail: { qr: 'VIBRATION_TEST_SUCCESS', status: 'success', message: 'Valid ticket', timestamp: Date.now(), vibrationPattern: [50, 30, 50] } })); }); await page.waitForTimeout(500); // Simulate error scan with different vibration await page.evaluate(() => { // Test vibration pattern for error navigator.vibrate([200]); // Single long vibration for error window.dispatchEvent(new CustomEvent('mock-scan-result', { detail: { qr: 'VIBRATION_TEST_ERROR', status: 'error', message: 'Invalid ticket', timestamp: Date.now(), vibrationPattern: [200] } })); }); await page.waitForTimeout(500); } // Scanner should remain functional regardless of vibration support await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should handle touch scrolling in settings panel', async ({ page }) => { // Open settings await page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)').tap(); await expect(page.locator('text=Gate/Zone')).toBeVisible(); // Test scrolling behavior if settings panel has scrollable content const settingsPanel = page.locator('.settings-panel, [data-testid="settings-panel"]').first(); if (await settingsPanel.isVisible()) { // Try to scroll within settings panel await settingsPanel.hover(); await page.mouse.wheel(0, 100); // Should handle scroll without affecting main page await expect(page.locator('text=Gate/Zone')).toBeVisible(); } // Test input focus behavior const gateInput = page.locator('input[placeholder*="Gate"]'); await gateInput.tap(); await expect(gateInput).toBeFocused(); // Virtual keyboard shouldn't break layout await gateInput.fill('Gate A - Touch Test'); await expect(gateInput).toHaveValue('Gate A - Touch Test'); // Dismiss keyboard by tapping outside await page.locator('h1').tap(); await expect(gateInput).not.toBeFocused(); }); test('should handle touch gestures and prevent zoom', async ({ page }) => { // Test that pinch-to-zoom is disabled for scanner area const videoElement = page.locator('video'); await expect(videoElement).toBeVisible(); // Check viewport meta tag prevents zooming const viewportMeta = await page.locator('meta[name="viewport"]').getAttribute('content'); expect(viewportMeta).toContain('user-scalable=no'); // Test touch events don't interfere with scanning const scannerArea = page.locator('.scanner-frame, [data-testid="scanner-frame"]').first(); if (await scannerArea.isVisible()) { // Simulate touch on scanner area await scannerArea.tap(); // Should not zoom or cause unwanted interactions await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); } // Test swipe gestures don't interfere await page.touchscreen.tap(200, 300); await page.waitForTimeout(100); // Scanner should remain stable await expect(page.locator('video')).toBeVisible(); }); }); test.describe('Device Orientation Handling', () => { const testEventId = 'evt-001'; test.beforeEach(async ({ page, context }) => { await context.grantPermissions(['camera']); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); }); test('should adapt layout for portrait orientation', async ({ page }) => { // Set portrait mobile viewport await page.setViewportSize({ width: 375, height: 667 }); await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Video should be properly sized for portrait const video = page.locator('video'); await expect(video).toBeVisible(); const videoBox = await video.boundingBox(); expect(videoBox?.height).toBeGreaterThan(videoBox?.width || 0); // Settings button should be accessible const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await expect(settingsButton).toBeVisible(); // Stats area should be visible await expect(page.locator('text=Online')).toBeVisible(); await expect(page.locator('text=Total:')).toBeVisible(); }); test('should adapt layout for landscape orientation', async ({ page }) => { // Set landscape mobile viewport await page.setViewportSize({ width: 667, height: 375 }); await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Video should adapt to landscape const video = page.locator('video'); await expect(video).toBeVisible(); const videoBox = await video.boundingBox(); expect(videoBox?.width).toBeGreaterThan(videoBox?.height || 0); // All critical elements should remain accessible in landscape await expect(page.locator('text=Online')).toBeVisible(); // Settings should still be accessible const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await settingsButton.tap(); await expect(page.locator('text=Gate/Zone')).toBeVisible(); // Settings panel should fit in landscape const gateInput = page.locator('input[placeholder*="Gate"]'); await expect(gateInput).toBeVisible(); const inputBox = await gateInput.boundingBox(); expect(inputBox?.y).toBeGreaterThan(0); // Should be within viewport }); test('should handle orientation changes smoothly', async ({ page }) => { // Start in portrait await page.setViewportSize({ width: 375, height: 667 }); await expect(page.locator('video')).toBeVisible(); // Open settings in portrait await page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)').tap(); await page.fill('input[placeholder*="Gate"]', 'Orientation Test'); // Rotate to landscape await page.setViewportSize({ width: 667, height: 375 }); await page.waitForTimeout(1000); // Allow for orientation change // Settings should remain open and functional await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Orientation Test'); await expect(page.locator('text=Gate/Zone')).toBeVisible(); // Video should still be working await expect(page.locator('video')).toBeVisible(); // Rotate back to portrait await page.setViewportSize({ width: 375, height: 667 }); await page.waitForTimeout(1000); // Everything should still work await expect(page.locator('video')).toBeVisible(); await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Orientation Test'); }); test('should lock orientation when supported', async ({ page }) => { // Test orientation lock API if available await page.setViewportSize({ width: 375, height: 667 }); const orientationLockSupported = await page.evaluate(async () => { if ('orientation' in screen && 'lock' in screen.orientation) { try { await screen.orientation.lock('portrait'); return { supported: true, locked: true }; } catch (error) { return { supported: true, locked: false, error: error.message }; } } return { supported: false }; }); console.log('Orientation lock support:', orientationLockSupported); // Scanner should work regardless of orientation lock support await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); await expect(page.locator('video')).toBeVisible(); }); }); test.describe('Camera Switching and Controls', () => { const testEventId = 'evt-001'; test.beforeEach(async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); await context.grantPermissions(['camera']); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); }); test('should detect available cameras', async ({ page }) => { const cameraInfo = await page.evaluate(async () => { if ('mediaDevices' in navigator && 'enumerateDevices' in navigator.mediaDevices) { try { const devices = await navigator.mediaDevices.enumerateDevices(); const cameras = devices.filter(device => device.kind === 'videoinput'); return { supported: true, cameraCount: cameras.length, cameras: cameras.map(camera => ({ deviceId: camera.deviceId, label: camera.label, groupId: camera.groupId })) }; } catch (error) { return { supported: true, error: error.message }; } } return { supported: false }; }); console.log('Camera detection:', cameraInfo); if (cameraInfo.supported && cameraInfo.cameraCount > 0) { expect(cameraInfo.cameraCount).toBeGreaterThan(0); } // Scanner should work with whatever cameras are available await expect(page.locator('video')).toBeVisible({ timeout: 10000 }); }); test('should switch between front and rear cameras', async ({ page }) => { // Check if camera switching UI exists const cameraSwitchButton = page.locator('button:has([data-testid="camera-switch"]), button:has(.lucide-camera-switch)'); if (await cameraSwitchButton.isVisible()) { // Test camera switching await cameraSwitchButton.tap(); await page.waitForTimeout(2000); // Allow camera switch time // Video should still be working after switch await expect(page.locator('video')).toBeVisible(); // Try switching back await cameraSwitchButton.tap(); await page.waitForTimeout(2000); await expect(page.locator('video')).toBeVisible(); } else { // If no camera switch button, verify single camera works await expect(page.locator('video')).toBeVisible(); } }); test('should handle camera constraints for mobile', async ({ page }) => { // Test mobile-specific camera constraints const cameraConstraints = await page.evaluate(async () => { if ('mediaDevices' in navigator) { try { const constraints = { video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'environment', // Rear camera preferred for scanning frameRate: { ideal: 30, max: 60 } } }; const stream = await navigator.mediaDevices.getUserMedia(constraints); const track = stream.getVideoTracks()[0]; const settings = track.getSettings(); // Clean up stream.getTracks().forEach(t => t.stop()); return { success: true, settings: { width: settings.width, height: settings.height, frameRate: settings.frameRate, facingMode: settings.facingMode } }; } catch (error) { return { success: false, error: error.message }; } } return { success: false, error: 'Media devices not supported' }; }); console.log('Camera constraints test:', cameraConstraints); // Scanner should work regardless of specific constraint support await expect(page.locator('video')).toBeVisible(); }); }); test.describe('Torch/Flashlight Functionality', () => { const testEventId = 'evt-001'; test.beforeEach(async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); await context.grantPermissions(['camera']); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); }); test('should detect torch capability', async ({ page }) => { await expect(page.locator('video')).toBeVisible({ timeout: 10000 }); const torchCapability = await page.evaluate(async () => { if ('mediaDevices' in navigator) { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }); const track = stream.getVideoTracks()[0]; const capabilities = track.getCapabilities(); // Clean up stream.getTracks().forEach(t => t.stop()); return { supported: 'torch' in capabilities, capabilities: capabilities.torch || false }; } catch (error) { return { supported: false, error: error.message }; } } return { supported: false }; }); console.log('Torch capability:', torchCapability); // Check if torch button is visible based on capability const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)'); if (torchCapability.supported) { // Should show torch button if supported await expect(torchButton).toBeVisible(); } // Scanner should work regardless of torch support await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should toggle torch on/off', async ({ page }) => { await expect(page.locator('video')).toBeVisible({ timeout: 10000 }); const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)'); if (await torchButton.isVisible()) { // Test torch toggle await torchButton.tap(); await page.waitForTimeout(500); // Verify torch state change (visual indication would vary) // In a real test, you might check for class changes or aria-pressed attributes const isPressed = await torchButton.evaluate(el => el.getAttribute('aria-pressed') === 'true' || el.classList.contains('active') || el.classList.contains('pressed') ); // Toggle back off await torchButton.tap(); await page.waitForTimeout(500); // Should toggle state const isPressedAfter = await torchButton.evaluate(el => el.getAttribute('aria-pressed') === 'true' || el.classList.contains('active') || el.classList.contains('pressed') ); expect(isPressed).not.toBe(isPressedAfter); } // Scanner functionality should not be affected await expect(page.locator('video')).toBeVisible(); }); test('should provide visual feedback for torch state', async ({ page }) => { const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)'); if (await torchButton.isVisible()) { // Get initial visual state const initialClasses = await torchButton.getAttribute('class'); const initialAriaPressed = await torchButton.getAttribute('aria-pressed'); // Toggle torch await torchButton.tap(); await page.waitForTimeout(300); // Check for visual changes const afterClasses = await torchButton.getAttribute('class'); const afterAriaPressed = await torchButton.getAttribute('aria-pressed'); // Should have some visual indication of state change const hasVisualChange = initialClasses !== afterClasses || initialAriaPressed !== afterAriaPressed; expect(hasVisualChange).toBe(true); // Button should still be accessible const buttonBox = await torchButton.boundingBox(); expect(buttonBox?.width).toBeGreaterThanOrEqual(44); expect(buttonBox?.height).toBeGreaterThanOrEqual(44); } }); }); test.describe('Permission Flows', () => { const testEventId = 'evt-001'; test('should handle camera permission denied gracefully', async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); // Don't grant camera permission await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); // Should show permission request or error message await page.waitForTimeout(3000); // Scanner should still load the UI await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Should show appropriate error state // (Implementation would show permission denied message or retry button) // Check if there's a retry/permission button const permissionButton = page.locator('button:has-text("Grant Camera Permission"), button:has-text("Retry"), button:has-text("Enable Camera")'); if (await permissionButton.isVisible()) { await permissionButton.tap(); await page.waitForTimeout(2000); } }); test('should request permissions on first visit', async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); // Clear all permissions first await context.clearPermissions(); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); // Navigate to scanner - should trigger permission request await page.goto(`/scan?eventId=${testEventId}`); await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Grant permission when prompted await context.grantPermissions(['camera']); // Should eventually show video await expect(page.locator('video')).toBeVisible({ timeout: 10000 }); }); test('should handle notification permissions for alerts', async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); await context.grantPermissions(['camera']); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); // Test notification permission request const notificationPermission = await page.evaluate(async () => { if ('Notification' in window) { const permission = await Notification.requestPermission(); return { supported: true, permission }; } return { supported: false }; }); console.log('Notification permission:', notificationPermission); if (notificationPermission.supported) { expect(['granted', 'denied', 'default']).toContain(notificationPermission.permission); } // Scanner should work regardless of notification permission await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should show helpful permission instructions', async ({ page, context }) => { await page.setViewportSize({ width: 375, height: 667 }); // Start without camera permission await context.clearPermissions(); await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await page.goto(`/scan?eventId=${testEventId}`); await page.waitForTimeout(3000); // Should show instructions or help text about camera permissions // (Implementation would show helpful guidance) await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Look for permission-related text const hasPermissionGuidance = await page.evaluate(() => { const text = document.body.innerText.toLowerCase(); return text.includes('camera') && ( text.includes('permission') || text.includes('access') || text.includes('allow') || text.includes('enable') ); }); // Should provide some guidance about permissions if (!hasPermissionGuidance) { console.log('Note: Consider adding permission guidance text for better UX'); } }); });