diff --git a/tests/e2e/responsive.spec.ts b/tests/e2e/responsive.spec.ts new file mode 100644 index 0000000..687d1d2 --- /dev/null +++ b/tests/e2e/responsive.spec.ts @@ -0,0 +1,612 @@ +import { test, expect } from '@playwright/test'; + +/** + * Responsive Design Test Suite + * + * This suite tests responsive design across three breakpoints: + * - Desktop: 1280x720 (standard desktop) + * - Tablet: 768x1024 (iPad portrait) + * - Mobile: 375x812 (iPhone X/11/12) + * + * Tests verify that: + * - Navigation adapts correctly (desktop nav vs mobile menu) + * - Content layouts adjust properly for each viewport + * - Touch targets are appropriately sized on mobile + * - Images and media scale correctly + * - No horizontal overflow occurs at any breakpoint + */ + +// Viewport configurations +const VIEWPORTS = { + desktop: { width: 1280, height: 720 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, +} as const; + +// Pages to test for responsive behavior +const PAGES_TO_TEST = [ + { path: '', name: 'Homepage' }, + { path: '/about', name: 'About' }, + { path: '/portfolio', name: 'Portfolio' }, + { path: '/contact', name: 'Contact' }, +] as const; + +/** + * Desktop Viewport Tests + * + * Verify desktop-specific layouts and behaviors. + */ +test.describe('Desktop Viewport', () => { + test.use({ viewport: VIEWPORTS.desktop }); + + test('Desktop navigation is visible and functional', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Desktop nav should be visible + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // Mobile menu button should be hidden + const mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).not.toBeVisible(); + + // All nav links should be visible + const navLinks = page.locator('nav .hidden.md\\:flex a'); + const linkCount = await navLinks.count(); + expect(linkCount).toBeGreaterThanOrEqual(5); + }); + + test('Hero section displays desktop layout', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Hero section should exist + const hero = page.locator('section#home'); + await expect(hero).toBeVisible(); + + // Desktop gradient (left side) should be visible + const desktopGradient = page.locator('.hidden.md\\:block').first(); + await expect(desktopGradient).toBeVisible(); + + // Scroll indicator should be visible on desktop + const scrollIndicator = page.locator('.hidden.sm\\:block').last(); + await expect(scrollIndicator).toBeVisible(); + }); + + test('Portfolio grid shows 2 columns on desktop', async ({ page }) => { + await page.goto('/de/portfolio', { + waitUntil: 'networkidle', + }); + + // Portfolio grid should exist + const portfolioGrid = page.locator('.grid.grid-cols-1.md\\:grid-cols-2'); + await expect(portfolioGrid).toBeVisible(); + + // Verify grid has items + const gridItems = portfolioGrid.locator('> div'); + const itemCount = await gridItems.count(); + expect(itemCount).toBeGreaterThan(0); + }); + + test('No horizontal scroll on desktop pages', async ({ page }) => { + for (const pageConfig of PAGES_TO_TEST) { + await page.goto(`/de${pageConfig.path}`, { + waitUntil: 'networkidle', + }); + + // Check for horizontal overflow + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + + expect(hasHorizontalScroll).toBeFalsy(); + } + }); + + test('Language switcher is accessible in desktop nav', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Language switcher should be visible + const languageSwitcher = page.locator('nav button[aria-label="Select language"]').first(); + await expect(languageSwitcher).toBeVisible(); + + // Click to open dropdown + await languageSwitcher.click(); + + // Verify dropdown opens + const dropdown = page.locator('[role="listbox"]').first(); + await expect(dropdown).toBeVisible(); + }); +}); + +/** + * Tablet Viewport Tests + * + * Verify tablet-specific layouts (768px breakpoint). + */ +test.describe('Tablet Viewport', () => { + test.use({ viewport: VIEWPORTS.tablet }); + + test('Tablet shows desktop navigation at md breakpoint', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // At 768px, desktop nav should be visible (md breakpoint) + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // Mobile menu button should be hidden at md breakpoint + const mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).not.toBeVisible(); + }); + + test('Hero section displays tablet/desktop layout', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Desktop gradient should be visible at md breakpoint + const desktopGradient = page.locator('.hidden.md\\:block').first(); + await expect(desktopGradient).toBeVisible(); + }); + + test('Portfolio grid shows 2 columns on tablet', async ({ page }) => { + await page.goto('/de/portfolio', { + waitUntil: 'networkidle', + }); + + // Portfolio grid should show 2 columns at md breakpoint + const portfolioGrid = page.locator('.grid.grid-cols-1.md\\:grid-cols-2'); + await expect(portfolioGrid).toBeVisible(); + }); + + test('Contact form is properly sized on tablet', async ({ page }) => { + await page.goto('/de/contact', { + waitUntil: 'networkidle', + }); + + // Form should be visible + const form = page.locator('form'); + await expect(form).toBeVisible(); + + // Form inputs should be usable + const nameInput = page.locator('input#name'); + const emailInput = page.locator('input#email'); + const messageTextarea = page.locator('textarea#message'); + + await expect(nameInput).toBeVisible(); + await expect(emailInput).toBeVisible(); + await expect(messageTextarea).toBeVisible(); + }); + + test('No horizontal scroll on tablet pages', async ({ page }) => { + for (const pageConfig of PAGES_TO_TEST) { + await page.goto(`/de${pageConfig.path}`, { + waitUntil: 'networkidle', + }); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + + expect(hasHorizontalScroll).toBeFalsy(); + } + }); +}); + +/** + * Mobile Viewport Tests + * + * Verify mobile-specific layouts and behaviors (< 768px). + */ +test.describe('Mobile Viewport', () => { + test.use({ viewport: VIEWPORTS.mobile }); + + test('Mobile menu button is visible', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Mobile menu button should be visible + const mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).toBeVisible(); + + // Desktop nav should be hidden + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).not.toBeVisible(); + }); + + test('Mobile menu opens and contains all navigation links', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open mobile menu + const menuButton = page.locator('button[aria-label="Open menu"]'); + await menuButton.click(); + + // Mobile menu should be visible + const mobileMenu = page.locator('#mobile-menu'); + await expect(mobileMenu).toBeVisible(); + + // Verify all navigation links are present + const navLinks = mobileMenu.locator('a[href^="/de"]'); + const linkCount = await navLinks.count(); + expect(linkCount).toBeGreaterThanOrEqual(5); + + // Close button should work + const closeButton = page.locator('#mobile-menu button[aria-label="Close sidebar"]'); + await closeButton.click(); + + // Menu should be closed (translated off-screen) + await expect(mobileMenu).toHaveClass(/translate-x-full/); + }); + + test('Hero section displays mobile layout', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Mobile gradient (bottom) should be visible + const mobileGradient = page.locator('.md\\:hidden.absolute').first(); + await expect(mobileGradient).toBeVisible(); + + // Desktop gradient should be hidden on mobile + const desktopGradient = page.locator('.hidden.md\\:block'); + await expect(desktopGradient.first()).not.toBeVisible(); + }); + + test('Portfolio grid shows 1 column on mobile', async ({ page }) => { + await page.goto('/de/portfolio', { + waitUntil: 'networkidle', + }); + + // Portfolio grid should exist + const portfolioGrid = page.locator('.grid.grid-cols-1'); + await expect(portfolioGrid).toBeVisible(); + + // On mobile, items should stack vertically (1 column) + const gridItems = portfolioGrid.locator('> div'); + const itemCount = await gridItems.count(); + + if (itemCount >= 2) { + const firstItem = gridItems.first(); + const secondItem = gridItems.nth(1); + + const firstBox = await firstItem.boundingBox(); + const secondBox = await secondItem.boundingBox(); + + if (firstBox && secondBox) { + // Items should be stacked (second item below first) + expect(secondBox.y).toBeGreaterThan(firstBox.y); + // Items should have similar width (not side by side) + expect(Math.abs(firstBox.x - secondBox.x)).toBeLessThan(50); + } + } + }); + + test('Contact form has touch-friendly inputs on mobile', async ({ page }) => { + await page.goto('/de/contact', { + waitUntil: 'networkidle', + }); + + // Form inputs should have minimum touch target size (44px recommended) + const nameInput = page.locator('input#name'); + const emailInput = page.locator('input#email'); + const submitButton = page.locator('button[type="submit"]'); + + await expect(nameInput).toBeVisible(); + await expect(emailInput).toBeVisible(); + await expect(submitButton).toBeVisible(); + + // Check input heights are touch-friendly + const nameBox = await nameInput.boundingBox(); + const emailBox = await emailInput.boundingBox(); + const buttonBox = await submitButton.boundingBox(); + + expect(nameBox?.height).toBeGreaterThanOrEqual(40); + expect(emailBox?.height).toBeGreaterThanOrEqual(40); + expect(buttonBox?.height).toBeGreaterThanOrEqual(40); + }); + + test('No significant horizontal scroll on mobile pages', async ({ page }) => { + for (const pageConfig of PAGES_TO_TEST) { + await page.goto(`/de${pageConfig.path}`, { + waitUntil: 'networkidle', + }); + + // Allow for small differences (e.g., scrollbar width, rounding errors) + // Consider horizontal scroll acceptable if within 20px of viewport width + const scrollInfo = await page.evaluate(() => { + return { + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + difference: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + + // Allow up to 20px overflow (accounts for scrollbars and minor layout issues) + expect(scrollInfo.difference).toBeLessThanOrEqual(20); + } + }); + + test('Images scale properly on mobile', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Get all images + const images = page.locator('img'); + const imageCount = await images.count(); + + for (let i = 0; i < imageCount; i++) { + const image = images.nth(i); + const isVisible = await image.isVisible(); + + if (isVisible) { + const box = await image.boundingBox(); + if (box) { + // Image should not exceed viewport width + expect(box.width).toBeLessThanOrEqual(VIEWPORTS.mobile.width); + } + } + } + }); + + test('Footer is accessible on mobile', async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Scroll to footer + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + + // Footer should be visible + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + + // Footer links should be accessible + const footerLinks = footer.locator('a'); + const linkCount = await footerLinks.count(); + expect(linkCount).toBeGreaterThan(0); + }); +}); + +/** + * Cross-Breakpoint Tests + * + * Verify consistent behavior across all breakpoints. + */ +test.describe('Cross-Breakpoint Consistency', () => { + test('Page content is visible at all breakpoints', async ({ page }) => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + await page.setViewportSize(viewport); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Main content should be visible + const main = page.locator('main'); + await expect(main).toBeVisible(); + + // Header should be visible + const header = page.locator('nav[role="navigation"]'); + await expect(header).toBeVisible(); + + // Footer should exist (may be below fold) + const footer = page.locator('footer'); + await expect(footer).toBeAttached(); + } + }); + + test('Navigation is accessible at all breakpoints', async ({ page }) => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + await page.setViewportSize(viewport); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Navigation should exist + const nav = page.locator('nav[role="navigation"]'); + await expect(nav).toBeVisible(); + + // Either desktop nav or mobile menu button should be visible + const desktopNav = page.locator('nav .hidden.md\\:flex'); + const mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + + const isDesktopNavVisible = await desktopNav.isVisible(); + const isMobileButtonVisible = await mobileMenuButton.isVisible(); + + // One of them must be visible + expect(isDesktopNavVisible || isMobileButtonVisible).toBeTruthy(); + } + }); + + test('Logo is visible at all breakpoints', async ({ page }) => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + await page.setViewportSize(viewport); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Logo should be visible + const logo = page.locator('nav a[href="/de"] img').first(); + await expect(logo).toBeVisible(); + } + }); + + test('Contact form works at all breakpoints', async ({ page }) => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + await page.setViewportSize(viewport); + + await page.goto('/de/contact', { + waitUntil: 'networkidle', + }); + + // Form fields should be visible + const nameInput = page.locator('input#name'); + const emailInput = page.locator('input#email'); + const messageTextarea = page.locator('textarea#message'); + const submitButton = page.locator('button[type="submit"]'); + + await expect(nameInput).toBeVisible(); + await expect(emailInput).toBeVisible(); + await expect(messageTextarea).toBeVisible(); + await expect(submitButton).toBeVisible(); + + // Form should be functional (can type) + await nameInput.fill('Test'); + await expect(nameInput).toHaveValue('Test'); + + // Clear for next iteration + await nameInput.clear(); + } + }); +}); + +/** + * Viewport Transition Tests + * + * Test behavior when viewport changes dynamically (window resize). + */ +test.describe('Viewport Transitions', () => { + test('Navigation adapts when resizing from desktop to mobile', async ({ page }) => { + // Start at desktop + await page.setViewportSize(VIEWPORTS.desktop); + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Desktop nav should be visible + let desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // Resize to mobile + await page.setViewportSize(VIEWPORTS.mobile); + + // Wait for layout to adjust + await page.waitForTimeout(300); + + // Mobile menu button should now be visible + const mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).toBeVisible(); + + // Desktop nav should be hidden + desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).not.toBeVisible(); + }); + + test('Navigation adapts when resizing from mobile to desktop', async ({ page }) => { + // Start at mobile + await page.setViewportSize(VIEWPORTS.mobile); + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Mobile menu button should be visible + let mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).toBeVisible(); + + // Resize to desktop + await page.setViewportSize(VIEWPORTS.desktop); + + // Wait for layout to adjust + await page.waitForTimeout(300); + + // Desktop nav should now be visible + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // Mobile menu button should be hidden + mobileMenuButton = page.locator('button[aria-label="Open menu"]'); + await expect(mobileMenuButton).not.toBeVisible(); + }); + + test('Mobile menu is hidden via CSS at desktop viewport', async ({ page }) => { + // Start at mobile + await page.setViewportSize(VIEWPORTS.mobile); + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open mobile menu + const menuButton = page.locator('button[aria-label="Open menu"]'); + await menuButton.click(); + + // Menu should be open + const mobileMenu = page.locator('#mobile-menu'); + await expect(mobileMenu).toBeVisible(); + + // Resize to desktop + await page.setViewportSize(VIEWPORTS.desktop); + + // Wait for layout to adjust + await page.waitForTimeout(300); + + // At desktop viewport, the mobile menu element is hidden via md:hidden class + // The menu element still exists but is not visible due to CSS + await expect(mobileMenu).toHaveClass(/md:hidden/); + + // Desktop navigation should be visible instead + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + }); +}); + +/** + * Orientation Tests + * + * Test behavior in different device orientations. + */ +test.describe('Device Orientation', () => { + test('Mobile landscape layout works correctly', async ({ page }) => { + // Mobile landscape (rotated iPhone) + await page.setViewportSize({ width: 812, height: 375 }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // At 812px width, should show desktop navigation + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // No horizontal scroll + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(hasHorizontalScroll).toBeFalsy(); + }); + + test('Tablet landscape layout works correctly', async ({ page }) => { + // Tablet landscape (rotated iPad) + await page.setViewportSize({ width: 1024, height: 768 }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Desktop navigation should be visible + const desktopNav = page.locator('nav .hidden.md\\:flex'); + await expect(desktopNav).toBeVisible(); + + // Portfolio grid should show 2 columns + await page.goto('/de/portfolio', { + waitUntil: 'networkidle', + }); + + const portfolioGrid = page.locator('.grid.grid-cols-1.md\\:grid-cols-2'); + await expect(portfolioGrid).toBeVisible(); + }); +});