diff --git a/e2e/cypress/integration/error-handling/form-validation.cy.ts b/e2e/cypress/integration/error-handling/form-validation.cy.ts new file mode 100644 index 000000000..3b93491dd --- /dev/null +++ b/e2e/cypress/integration/error-handling/form-validation.cy.ts @@ -0,0 +1,54 @@ +describe('Error Handling - Form Validation', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Required Fields', () => { + it('should show error for empty required field', () => { + cy.getByTestId('search-button').click() + cy.getByTestId('departure-error').should('be.visible') + }) + + it('should show all required errors', () => { + cy.getByTestId('search-button').click() + cy.getByTestId('departure-error').should('be.visible') + cy.getByTestId('arrival-error').should('be.visible') + }) + + it('should clear error on input', () => { + cy.getByTestId('search-button').click() + cy.getByTestId('departure-error').should('be.visible') + cy.getByTestId('departure-input').type('SVO') + cy.getByTestId('departure-error').should('not.exist') + }) + }) + + describe('Format Validation', () => { + it('should validate email format', () => { + cy.getByTestId('email-input').type('invalid-email') + cy.getByTestId('email-error').should('be.visible') + }) + + it('should validate phone format', () => { + cy.getByTestId('phone-input').type('123') + cy.getByTestId('phone-error').should('be.visible') + }) + + it('should validate date format', () => { + cy.getByTestId('date-input').type('invalid-date') + cy.getByTestId('date-error').should('be.visible') + }) + }) + + describe('Range Validation', () => { + it('should validate min length', () => { + cy.getByTestId('password-input').type('123') + cy.getByTestId('password-error').should('contain', 'at least') + }) + + it('should validate max length', () => { + cy.getByTestId('text-input').type('a'.repeat(300)) + cy.getByTestId('text-error').should('contain', 'maximum') + }) + }) +}) diff --git a/e2e/cypress/integration/error-handling/network-errors.cy.ts b/e2e/cypress/integration/error-handling/network-errors.cy.ts new file mode 100644 index 000000000..e39b61685 --- /dev/null +++ b/e2e/cypress/integration/error-handling/network-errors.cy.ts @@ -0,0 +1,91 @@ +describe('Error Handling - Network Errors', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('API Failures', () => { + it('should handle 500 server error', () => { + cy.intercept('GET', '**/api/flights**', { + statusCode: 500, + body: { error: 'Server error' }, + }).as('serverError') + cy.getByTestId('search-button').click() + cy.wait('@serverError') + cy.getByTestId('error-message').should('be.visible') + }) + + it('should handle 404 not found', () => { + cy.intercept('GET', '**/api/flights**', { + statusCode: 404, + body: { error: 'Not found' }, + }).as('notFound') + cy.getByTestId('search-button').click() + cy.wait('@notFound') + cy.getByTestId('not-found-message').should('be.visible') + }) + + it('should handle timeout', () => { + cy.intercept('GET', '**/api/flights**', req => { + req.destroy() + }).as('timeout') + cy.getByTestId('search-button').click() + cy.getByTestId('timeout-message').should('be.visible') + }) + + it('should show retry button', () => { + cy.intercept('GET', '**/api/flights**', { + statusCode: 500, + body: { error: 'Error' }, + }).as('error') + cy.getByTestId('search-button').click() + cy.wait('@error') + cy.getByTestId('retry-button').should('be.visible') + }) + + it('should retry on button click', () => { + cy.intercept('GET', '**/api/flights**', { + statusCode: 500, + body: { error: 'Error' }, + }).as('firstError') + cy.intercept('GET', '**/api/flights**', { + statusCode: 200, + body: { flights: [] }, + }).as('success') + + cy.getByTestId('search-button').click() + cy.wait('@firstError') + cy.getByTestId('retry-button').click() + cy.wait('@success') + cy.getByTestId('error-message').should('not.exist') + }) + }) + + describe('Offline Detection', () => { + it('should show offline message when offline', () => { + cy.window().then(win => { + win.dispatchEvent(new Event('offline')) + }) + cy.getByTestId('offline-banner').should('be.visible') + }) + + it('should hide offline message when online', () => { + cy.window().then(win => { + win.dispatchEvent(new Event('offline')) + win.dispatchEvent(new Event('online')) + }) + cy.getByTestId('offline-banner').should('not.exist') + }) + }) + + describe('Accessibility', () => { + it('should announce errors', () => { + cy.intercept('GET', '**/api/flights**', { + statusCode: 500, + body: { error: 'Error' }, + }).as('error') + cy.getByTestId('search-button').click() + cy.wait('@error') + cy.getByTestId('error-message').should('have.attr', 'role', 'alert') + }) + }) +}) diff --git a/e2e/cypress/integration/error-handling/performance.cy.ts b/e2e/cypress/integration/error-handling/performance.cy.ts new file mode 100644 index 000000000..7d9273d86 --- /dev/null +++ b/e2e/cypress/integration/error-handling/performance.cy.ts @@ -0,0 +1,58 @@ +describe('Error Handling & Performance - Performance Tests', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Page Load Time', () => { + it('should load homepage quickly', () => { + cy.window().then(win => { + const perfData = win.performance.timing + const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart + expect(pageLoadTime).to.be.lessThan(3000) + }) + }) + + it('should display content before full load', () => { + cy.getByTestId('main-content').should('be.visible') + }) + + it('should lazy load images', () => { + cy.getByTestId('flight-item-image').should('have.attr', 'loading', 'lazy') + }) + }) + + describe('Search Performance', () => { + it('should return search results quickly', () => { + cy.window().then(win => { + const start = win.performance.now() + cy.getByTestId('search-button').click() + cy.getByTestId('flight-item').should('have.length.greaterThan', 0) + cy.window().then(endWin => { + const end = endWin.performance.now() + expect(end - start).to.be.lessThan(2000) + }) + }) + }) + }) + + describe('Memory Usage', () => { + it('should not leak memory on navigation', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.visit('http://localhost:3001/flights') + cy.window().then(win => { + expect(win.performance.memory).to.exist + }) + }) + }) + + describe('Rendering Performance', () => { + it('should render list without jank', () => { + cy.getByTestId('flight-list').should('be.visible') + cy.window().then(win => { + const fps = win.performance.getEntriesByName('flight-render') + expect(fps.length).to.be.greaterThan(0) + }) + }) + }) +}) diff --git a/e2e/cypress/integration/error-handling/session-timeout.cy.ts b/e2e/cypress/integration/error-handling/session-timeout.cy.ts new file mode 100644 index 000000000..f748b7ce3 --- /dev/null +++ b/e2e/cypress/integration/error-handling/session-timeout.cy.ts @@ -0,0 +1,34 @@ +describe('Error Handling - Session Timeout', () => { + describe('Session Expiration', () => { + it('should show timeout warning before expiry', () => { + cy.visit('http://localhost:3001/booking') + cy.clock() + cy.tick(14 * 60 * 1000) // 14 minutes + cy.getByTestId('session-timeout-warning').should('be.visible') + }) + + it('should logout on timeout', () => { + cy.visit('http://localhost:3001/booking', { headers: { Authorization: 'Bearer token' } }) + cy.clock() + cy.tick(16 * 60 * 1000) // 16 minutes + cy.url().should('include', '/login') + }) + + it('should allow extending session', () => { + cy.visit('http://localhost:3001/booking') + cy.clock() + cy.tick(14 * 60 * 1000) + cy.getByTestId('extend-session-button').click() + cy.getByTestId('session-timeout-warning').should('not.exist') + }) + }) + + describe('Token Refresh', () => { + it('should refresh token automatically', () => { + cy.visit('http://localhost:3001/flights') + cy.window().then(win => { + expect(win.localStorage.getItem('auth_token')).to.exist + }) + }) + }) +}) diff --git a/e2e/cypress/integration/i18n/currency.cy.ts b/e2e/cypress/integration/i18n/currency.cy.ts new file mode 100644 index 000000000..7e73a47a8 --- /dev/null +++ b/e2e/cypress/integration/i18n/currency.cy.ts @@ -0,0 +1,42 @@ +describe('i18n - Currency Formatting', () => { + beforeEach(() => { + cy.visit('http://localhost:3001/flights') + }) + + describe('Currency Display', () => { + it('should display prices with ruble symbol', () => { + cy.getByTestId('flight-price').should('contain', '₽') + }) + + it('should format large numbers', () => { + cy.getByTestId('flight-price').should('contain', '0') + }) + + it('should use correct decimal separator', () => { + cy.getByTestId('flight-price').then($el => { + const text = $el.text() + expect(text).to.match(/\d+[\s,]\d+/) + }) + }) + + it('should add thousand separator', () => { + cy.getByTestId('large-price').then($el => { + const text = $el.text() + expect(text).to.match(/\d+\s\d+/) + }) + }) + }) + + describe('Price Formatting', () => { + it('should calculate total price correctly', () => { + cy.getByTestId('unit-price').then($unit => { + const unitPrice = parseFloat($unit.text()) + cy.getByTestId('quantity').then($qty => { + const qty = parseInt($qty.text()) + const expectedTotal = unitPrice * qty + cy.getByTestId('total-price').should('contain', expectedTotal.toString()) + }) + }) + }) + }) +}) diff --git a/e2e/cypress/integration/i18n/date-localization.cy.ts b/e2e/cypress/integration/i18n/date-localization.cy.ts new file mode 100644 index 000000000..9e5c8c965 --- /dev/null +++ b/e2e/cypress/integration/i18n/date-localization.cy.ts @@ -0,0 +1,50 @@ +describe('i18n - Date & Time Localization', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Date Format', () => { + it('should format date in English', () => { + cy.getByTestId('language-selector').select('en') + cy.getByTestId('date-display').should('contain', 'May') + }) + + it('should format date in Russian', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('date-display').should('contain', 'май') + }) + + it('should use correct date separator', () => { + cy.getByTestId('language-selector').select('en') + cy.getByTestId('date-value').then($el => { + const text = $el.text() + expect(text).to.match(/\d{1,2}\/\d{1,2}\/\d{4}/) + }) + }) + }) + + describe('Time Format', () => { + it('should format time correctly', () => { + cy.getByTestId('time-display').should('contain', ':') + }) + + it('should use 24-hour format', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('time-value').should('match', /\d{2}:\d{2}/) + }) + }) + + describe('Calendar Localization', () => { + it('should translate month names', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('date-input').click() + cy.getByTestId('calendar-month').should('contain', 'май') + }) + + it('should translate day names', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('date-input').click() + cy.getByTestId('calendar-day-header').should('contain', 'Пн') + }) + }) +}) diff --git a/e2e/cypress/integration/i18n/language-switching.cy.ts b/e2e/cypress/integration/i18n/language-switching.cy.ts new file mode 100644 index 000000000..265514965 --- /dev/null +++ b/e2e/cypress/integration/i18n/language-switching.cy.ts @@ -0,0 +1,51 @@ +describe('i18n - Language Switching', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Language Selection', () => { + it('should display language selector', () => { + cy.getByTestId('language-selector').should('be.visible') + }) + + it('should switch to English', () => { + cy.getByTestId('language-selector').select('en') + cy.getByTestId('page-title').should('contain', 'Flights') + }) + + it('should switch to Russian', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('page-title').should('contain', 'Рейсы') + }) + + it('should persist language selection', () => { + cy.getByTestId('language-selector').select('ru') + cy.reload() + cy.getByTestId('language-selector').should('have.value', 'ru') + }) + }) + + describe('Content Translation', () => { + it('should translate all labels', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('search-button').should('contain', 'Поиск') + }) + + it('should translate form fields', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('departure-input').should('have.attr', 'placeholder').and('contain', 'Из') + }) + + it('should translate error messages', () => { + cy.getByTestId('language-selector').select('ru') + cy.getByTestId('search-button').click() + cy.getByTestId('error-message').should('contain', 'обязательно') + }) + }) + + describe('Accessibility', () => { + it('should have accessible language selector', () => { + cy.getByTestId('language-selector').should('have.attr', 'aria-label') + }) + }) +}) diff --git a/e2e/cypress/integration/i18n/locale-persistence.cy.ts b/e2e/cypress/integration/i18n/locale-persistence.cy.ts new file mode 100644 index 000000000..6724e99a6 --- /dev/null +++ b/e2e/cypress/integration/i18n/locale-persistence.cy.ts @@ -0,0 +1,50 @@ +describe('i18n - Locale Persistence', () => { + describe('Language Persistence', () => { + it('should persist language selection in localStorage', () => { + cy.visit('http://localhost:3001') + cy.getByTestId('language-selector').select('ru') + cy.window().then(win => { + expect(win.localStorage.getItem('lang')).to.equal('ru') + }) + }) + + it('should restore language on page reload', () => { + cy.visit('http://localhost:3001') + cy.getByTestId('language-selector').select('ru') + cy.reload() + cy.getByTestId('language-selector').should('have.value', 'ru') + cy.getByTestId('page-title').should('contain', 'Рейсы') + }) + + it('should persist across different pages', () => { + cy.visit('http://localhost:3001') + cy.getByTestId('language-selector').select('ru') + cy.visit('http://localhost:3001/flights') + cy.getByTestId('language-selector').should('have.value', 'ru') + }) + + it('should set language from browser setting', () => { + cy.visit('http://localhost:3001') + cy.window().then(win => { + const navLang = win.navigator.language.split('-')[0] + expect(['en', 'ru']).to.include(navLang) + }) + }) + }) + + describe('Locale Preferences', () => { + it('should persist date format preference', () => { + cy.visit('http://localhost:3001') + cy.getByTestId('date-format-select').select('DD/MM/YYYY') + cy.reload() + cy.getByTestId('date-format-select').should('have.value', 'DD/MM/YYYY') + }) + + it('should persist timezone preference', () => { + cy.visit('http://localhost:3001') + cy.getByTestId('timezone-select').select('GMT+3') + cy.reload() + cy.getByTestId('timezone-select').should('have.value', 'GMT+3') + }) + }) +}) diff --git a/e2e/cypress/integration/i18n/text-direction.cy.ts b/e2e/cypress/integration/i18n/text-direction.cy.ts new file mode 100644 index 000000000..279479010 --- /dev/null +++ b/e2e/cypress/integration/i18n/text-direction.cy.ts @@ -0,0 +1,35 @@ +describe('i18n - Text Direction (RTL/LTR)', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Text Direction', () => { + it('should use LTR for English', () => { + cy.getByTestId('language-selector').select('en') + cy.get('html').should('have.attr', 'dir', 'ltr') + }) + + it('should mirror layout for RTL languages', () => { + cy.getByTestId('language-selector').select('ar') + cy.get('html').should('have.attr', 'dir', 'rtl') + }) + + it('should align text correctly', () => { + cy.getByTestId('language-selector').select('ar') + cy.getByTestId('main-content').should('have.css', 'text-align', 'right') + }) + }) + + describe('Layout Mirroring', () => { + it('should mirror sidebar position', () => { + cy.viewport(1440, 900) + cy.getByTestId('language-selector').select('ar') + cy.getByTestId('sidebar').should('have.css', 'right', '0') + }) + + it('should mirror padding and margins', () => { + cy.getByTestId('language-selector').select('ar') + cy.getByTestId('main-content').should('have.css', 'margin-right') + }) + }) +}) diff --git a/e2e/cypress/integration/navigation/404.cy.ts b/e2e/cypress/integration/navigation/404.cy.ts new file mode 100644 index 000000000..c4f9ead84 --- /dev/null +++ b/e2e/cypress/integration/navigation/404.cy.ts @@ -0,0 +1,32 @@ +describe('Navigation - 404 Error Page', () => { + describe('404 Page Display', () => { + it('should show 404 for invalid route', () => { + cy.visit('http://localhost:3001/invalid-page', { failOnStatusCode: false }) + cy.getByTestId('error-404-title').should('contain', '404') + }) + + it('should display error message', () => { + cy.visit('http://localhost:3001/invalid', { failOnStatusCode: false }) + cy.getByTestId('error-message').should('be.visible') + }) + + it('should have back button', () => { + cy.visit('http://localhost:3001/invalid', { failOnStatusCode: false }) + cy.getByTestId('back-button').click() + cy.url().should('not.include', 'invalid') + }) + + it('should have home link', () => { + cy.visit('http://localhost:3001/invalid', { failOnStatusCode: false }) + cy.getByTestId('home-link').click() + cy.url().should('include', '/') + }) + }) + + describe('Accessibility', () => { + it('should have proper heading', () => { + cy.visit('http://localhost:3001/invalid', { failOnStatusCode: false }) + cy.getByTestId('error-heading').should('have.attr', 'role', 'heading') + }) + }) +}) diff --git a/e2e/cypress/integration/navigation/back-forward.cy.ts b/e2e/cypress/integration/navigation/back-forward.cy.ts new file mode 100644 index 000000000..95ca3e897 --- /dev/null +++ b/e2e/cypress/integration/navigation/back-forward.cy.ts @@ -0,0 +1,74 @@ +import { uiHelpers } from '../../support/helpers/ui-helpers' + +describe('Navigation - Browser Back/Forward', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Back Button', () => { + it('should go back in history', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.url().should('include', '/flights') + }) + + it('should disable back at start', () => { + cy.visit('http://localhost:3001') + cy.window().then(win => { + expect(win.history.length).to.equal(1) + }) + }) + + it('should work multiple times', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.visit('http://localhost:3001/booking') + cy.go('back') + cy.go('back') + cy.url().should('include', '/flights') + }) + }) + + describe('Forward Button', () => { + it('should go forward in history', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.go('forward') + cy.url().should('include', '/schedule') + }) + + it('should disable forward at end', () => { + cy.visit('http://localhost:3001/flights') + cy.go('forward') + cy.url().should('include', '/flights') + }) + }) + + describe('History State', () => { + it('should preserve state on back', () => { + cy.visit('http://localhost:3001/flights') + cy.getByTestId('search-input').type('SVO') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.getByTestId('search-input').should('have.value', 'SVO') + }) + + it('should maintain scroll position', () => { + cy.visit('http://localhost:3001/flights') + cy.scrollTo('bottom') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.window().its('scrollY').should('be.greaterThan', 0) + }) + }) + + describe('Accessibility', () => { + it('should announce navigation changes', () => { + cy.visit('http://localhost:3001/flights') + cy.go('back') + cy.getByTestId('page-title').should('have.attr', 'role', 'status') + }) + }) +}) diff --git a/e2e/cypress/integration/navigation/breadcrumb.cy.ts b/e2e/cypress/integration/navigation/breadcrumb.cy.ts new file mode 100644 index 000000000..256692d8a --- /dev/null +++ b/e2e/cypress/integration/navigation/breadcrumb.cy.ts @@ -0,0 +1,31 @@ +describe('Navigation - Breadcrumb', () => { + describe('Breadcrumb Display', () => { + it('should display breadcrumbs', () => { + cy.visit('http://localhost:3001/flights/details') + cy.getByTestId('breadcrumb').should('be.visible') + }) + + it('should show correct path', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-item').should('have.length.greaterThan', 1) + }) + + it('should navigate using breadcrumb', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-link').first().click() + cy.url().should('not.include', 'details') + }) + }) + + describe('Accessibility', () => { + it('should have navigation role', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb').should('have.attr', 'role', 'navigation') + }) + + it('should mark current page', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-current').should('have.attr', 'aria-current') + }) + }) +}) diff --git a/e2e/cypress/integration/navigation/links.cy.ts b/e2e/cypress/integration/navigation/links.cy.ts new file mode 100644 index 000000000..f2d2d6912 --- /dev/null +++ b/e2e/cypress/integration/navigation/links.cy.ts @@ -0,0 +1,57 @@ +describe('Navigation - Link Navigation', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Internal Links', () => { + it('should navigate with internal link', () => { + cy.getByTestId('flights-link').click() + cy.url().should('include', '/flights') + }) + + it('should not reload page', () => { + let reloaded = false + cy.window().then(win => { + win.onbeforeunload = () => { reloaded = true } + }) + cy.getByTestId('flights-link').click() + cy.window().then(win => { + expect(reloaded).to.be.false + }) + }) + }) + + describe('External Links', () => { + it('should have target blank', () => { + cy.getByTestId('external-link').should('have.attr', 'target', '_blank') + }) + + it('should have rel attribute', () => { + cy.getByTestId('external-link').should('have.attr', 'rel') + }) + }) + + describe('Link States', () => { + it('should show visited state', () => { + cy.getByTestId('flights-link').click() + cy.go('back') + cy.getByTestId('flights-link').should('have.class', 'visited') + }) + + it('should show hover state', () => { + cy.getByTestId('flights-link').trigger('mouseenter') + cy.getByTestId('flights-link').should('have.class', 'hover') + }) + }) + + describe('Accessibility', () => { + it('should have descriptive link text', () => { + cy.getByTestId('flights-link').should('contain', 'Flights') + }) + + it('should be keyboard accessible', () => { + cy.getByTestId('flights-link').focus().type('{enter}') + cy.url().should('include', '/flights') + }) + }) +}) diff --git a/e2e/cypress/integration/navigation/routes.cy.ts b/e2e/cypress/integration/navigation/routes.cy.ts new file mode 100644 index 000000000..36f471b1a --- /dev/null +++ b/e2e/cypress/integration/navigation/routes.cy.ts @@ -0,0 +1,165 @@ +import { uiHelpers } from '../../support/helpers/ui-helpers' + +describe('Navigation - Routes & Links', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Navigation Links', () => { + it('should navigate to home page', () => { + cy.getByTestId('nav-home-link').click() + cy.url().should('include', '/') + }) + + it('should navigate to flights page', () => { + cy.getByTestId('nav-flights-link').click() + cy.url().should('include', '/flights') + }) + + it('should navigate to schedule page', () => { + cy.getByTestId('nav-schedule-link').click() + cy.url().should('include', '/schedule') + }) + + it('should navigate to booking page', () => { + cy.getByTestId('nav-booking-link').click() + cy.url().should('include', '/booking') + }) + + it('should navigate to account page', () => { + cy.getByTestId('nav-account-link').click() + cy.url().should('include', '/account') + }) + + it('should highlight active route', () => { + cy.getByTestId('nav-flights-link').click() + cy.getByTestId('nav-flights-link').should('have.class', 'active') + }) + }) + + describe('Navigation Menu', () => { + it('should display navigation menu', () => { + cy.getByTestId('navigation-menu').should('be.visible') + }) + + it('should display all menu items', () => { + cy.getByTestId('menu-item').should('have.length.greaterThan', 3) + }) + + it('should show dropdown menu', () => { + cy.getByTestId('more-menu-button').click() + cy.getByTestId('dropdown-menu').should('be.visible') + }) + + it('should navigate from dropdown', () => { + cy.getByTestId('more-menu-button').click() + cy.getByTestId('dropdown-item').first().click() + cy.url().should('not.equal', 'http://localhost:3001/') + }) + }) + + describe('Route Parameters', () => { + it('should pass route parameters', () => { + cy.visit('http://localhost:3001/flights/SU123') + cy.url().should('include', '/flights/SU123') + }) + + it('should load page with parameters', () => { + cy.visit('http://localhost:3001/booking/123') + cy.getByTestId('booking-id').should('contain', '123') + }) + + it('should handle query parameters', () => { + cy.visit('http://localhost:3001/flights?from=SVO&to=LED') + cy.url().should('include', 'from=SVO') + cy.url().should('include', 'to=LED') + }) + }) + + describe('Breadcrumb Navigation', () => { + it('should display breadcrumbs', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-list').should('be.visible') + }) + + it('should show current page in breadcrumb', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-current').should('contain', 'Details') + }) + + it('should navigate using breadcrumb', () => { + cy.visit('http://localhost:3001/flights/SU123/details') + cy.getByTestId('breadcrumb-link').first().click() + cy.url().should('include', '/flights') + }) + }) + + describe('Programmatic Navigation', () => { + it('should navigate with router', () => { + cy.window().then(win => { + win.router?.push('/flights') + }) + cy.url().should('include', '/flights') + }) + + it('should go back', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.url().should('include', '/flights') + }) + + it('should go forward', () => { + cy.visit('http://localhost:3001/flights') + cy.visit('http://localhost:3001/schedule') + cy.go('back') + cy.go('forward') + cy.url().should('include', '/schedule') + }) + }) + + describe('Link Opening', () => { + it('should open link in same tab', () => { + cy.getByTestId('nav-flights-link').click() + cy.url().should('include', '/flights') + }) + + it('should open external link', () => { + cy.getByTestId('external-link').should('have.attr', 'target', '_blank') + }) + + it('should not have blank target on internal links', () => { + cy.getByTestId('nav-flights-link').should('not.have.attr', 'target', '_blank') + }) + }) + + describe('Error Routes', () => { + it('should show 404 page', () => { + cy.visit('http://localhost:3001/nonexistent', { failOnStatusCode: false }) + cy.getByTestId('error-404-page').should('be.visible') + }) + + it('should have back button on error page', () => { + cy.visit('http://localhost:3001/nonexistent', { failOnStatusCode: false }) + cy.getByTestId('error-back-button').click() + cy.url().should('not.include', '/nonexistent') + }) + }) + + describe('Accessibility', () => { + it('should have accessible navigation', () => { + cy.getByTestId('navigation-menu').should('have.attr', 'role', 'navigation') + }) + + it('should announce active route', () => { + cy.getByTestId('nav-flights-link').click() + cy.getByTestId('nav-flights-link').should('have.attr', 'aria-current', 'page') + }) + + it('should support keyboard navigation', () => { + cy.getByTestId('nav-home-link').focus() + cy.getByTestId('nav-home-link').type('{tab}') + cy.getByTestId('nav-flights-link').should('have.focus') + }) + }) +}) diff --git a/e2e/cypress/integration/responsive/desktop.cy.ts b/e2e/cypress/integration/responsive/desktop.cy.ts new file mode 100644 index 000000000..c94f2f50a --- /dev/null +++ b/e2e/cypress/integration/responsive/desktop.cy.ts @@ -0,0 +1,55 @@ +describe('Responsive - Desktop Layout (1440px)', () => { + beforeEach(() => { + cy.viewport(1440, 900) + cy.visit('http://localhost:3001') + }) + + describe('Layout', () => { + it('should display full layout', () => { + cy.getByTestId('header').should('be.visible') + cy.getByTestId('sidebar').should('be.visible') + cy.getByTestId('main-content').should('be.visible') + }) + + it('should display sidebar', () => { + cy.getByTestId('sidebar').should('not.have.css', 'display', 'none') + }) + + it('should display all navigation items', () => { + cy.getByTestId('nav-item').should('have.length.greaterThan', 3) + }) + + it('should display content without horizontal scroll', () => { + cy.window().then(win => { + expect(win.innerWidth).to.be.greaterThan(win.document.body.scrollWidth) + }) + }) + + it('should have proper spacing', () => { + cy.getByTestId('main-content').should('have.css', 'margin-left') + }) + }) + + describe('Components', () => { + it('should display buttons inline', () => { + cy.getByTestId('button-group').within(() => { + cy.getByTestId('button').each($btn => { + expect($btn).to.have.css('display') + }) + }) + }) + + it('should show multi-column layout', () => { + cy.getByTestId('grid-container').should('have.css', 'grid-template-columns') + }) + }) + + describe('Accessibility', () => { + it('should be fully accessible', () => { + cy.getByTestId('header').should('have.attr', 'role') + cy.getByTestId('nav-item').each($item => { + cy.wrap($item).should('be.visible') + }) + }) + }) +}) diff --git a/e2e/cypress/integration/responsive/mobile.cy.ts b/e2e/cypress/integration/responsive/mobile.cy.ts new file mode 100644 index 000000000..134e02da4 --- /dev/null +++ b/e2e/cypress/integration/responsive/mobile.cy.ts @@ -0,0 +1,49 @@ +describe('Responsive - Mobile Layout (375px)', () => { + beforeEach(() => { + cy.viewport(375, 667) + cy.visit('http://localhost:3001') + }) + + describe('Layout', () => { + it('should display mobile layout', () => { + cy.getByTestId('header').should('be.visible') + cy.getByTestId('main-content').should('be.visible') + }) + + it('should stack layout vertically', () => { + cy.getByTestId('page-layout').should('have.css', 'flex-direction', 'column') + }) + + it('should have full-width content', () => { + cy.getByTestId('main-content').should('have.css', 'width', '100%') + }) + + it('should not have horizontal scroll', () => { + cy.window().then(win => { + expect(win.document.body.scrollWidth).to.equal(win.innerWidth) + }) + }) + }) + + describe('Navigation', () => { + it('should show hamburger menu', () => { + cy.getByTestId('hamburger-menu').should('be.visible') + }) + + it('should hide sidebar by default', () => { + cy.getByTestId('sidebar').should('not.be.visible') + }) + + it('should show slide-out menu', () => { + cy.getByTestId('hamburger-menu').click() + cy.getByTestId('mobile-menu').should('be.visible') + }) + }) + + describe('Touch Targets', () => { + it('should have proper touch target size', () => { + cy.getByTestId('button').should('have.css', 'min-height').and('not.equal', '0px') + cy.getByTestId('button').should('have.css', 'min-width').and('not.equal', '0px') + }) + }) +}) diff --git a/e2e/cypress/integration/responsive/tablet.cy.ts b/e2e/cypress/integration/responsive/tablet.cy.ts new file mode 100644 index 000000000..51ece06ba --- /dev/null +++ b/e2e/cypress/integration/responsive/tablet.cy.ts @@ -0,0 +1,38 @@ +describe('Responsive - Tablet Layout (768px)', () => { + beforeEach(() => { + cy.viewport(768, 1024) + cy.visit('http://localhost:3001') + }) + + describe('Layout', () => { + it('should display tablet layout', () => { + cy.getByTestId('header').should('be.visible') + cy.getByTestId('main-content').should('be.visible') + }) + + it('should hide or collapse sidebar', () => { + cy.getByTestId('sidebar').should('not.be.visible') + }) + + it('should show hamburger menu', () => { + cy.getByTestId('hamburger-menu').should('be.visible') + }) + + it('should not have horizontal scroll', () => { + cy.window().then(win => { + expect(win.document.body.scrollWidth).to.be.lessThanOrEqual(win.innerWidth) + }) + }) + }) + + describe('Navigation', () => { + it('should open menu on hamburger click', () => { + cy.getByTestId('hamburger-menu').click() + cy.getByTestId('mobile-menu').should('be.visible') + }) + + it('should have touch-friendly spacing', () => { + cy.getByTestId('button').should('have.css', 'min-height', '48px') + }) + }) +}) diff --git a/e2e/cypress/integration/responsive/touch-interactions.cy.ts b/e2e/cypress/integration/responsive/touch-interactions.cy.ts new file mode 100644 index 000000000..5ef60254b --- /dev/null +++ b/e2e/cypress/integration/responsive/touch-interactions.cy.ts @@ -0,0 +1,39 @@ +describe('Responsive - Touch Interactions', () => { + beforeEach(() => { + cy.viewport('iphone-x') + cy.visit('http://localhost:3001') + }) + + describe('Touch Targets', () => { + it('should have proper touch target size', () => { + cy.getByTestId('button').should('have.css', 'min-height', '48px') + cy.getByTestId('button').should('have.css', 'min-width', '48px') + }) + + it('should have adequate spacing between targets', () => { + cy.getByTestId('button').each(($btn, i) => { + if (i > 0) { + cy.wrap($btn).should('have.css', 'margin') + } + }) + }) + }) + + describe('Swipe Gestures', () => { + it('should handle swipe right', () => { + cy.getByTestId('carousel').swipe('right') + cy.getByTestId('carousel-item').first().should('not.be.visible') + }) + + it('should handle swipe left', () => { + cy.getByTestId('carousel').swipe('left') + cy.getByTestId('carousel-item').last().should('be.visible') + }) + }) + + describe('Pinch Zoom', () => { + it('should allow pinch zoom on images', () => { + cy.getByTestId('zoomable-image').should('be.visible') + }) + }) +}) diff --git a/e2e/cypress/integration/responsive/viewport-resize.cy.ts b/e2e/cypress/integration/responsive/viewport-resize.cy.ts new file mode 100644 index 000000000..d0582bbb3 --- /dev/null +++ b/e2e/cypress/integration/responsive/viewport-resize.cy.ts @@ -0,0 +1,40 @@ +describe('Responsive - Viewport Resize', () => { + beforeEach(() => { + cy.visit('http://localhost:3001') + }) + + describe('Dynamic Resize', () => { + it('should adapt to viewport resize', () => { + cy.viewport(1440, 900) + cy.getByTestId('main-content').should('be.visible') + cy.viewport('iphone-x') + cy.getByTestId('hamburger-menu').should('be.visible') + }) + + it('should update layout on resize', () => { + cy.viewport(1440, 900) + cy.getByTestId('sidebar').should('be.visible') + cy.viewport(375, 667) + cy.getByTestId('sidebar').should('not.be.visible') + }) + + it('should maintain functionality after resize', () => { + cy.viewport(1440, 900) + cy.getByTestId('search-input').type('test') + cy.viewport('iphone-x') + cy.getByTestId('search-input').should('have.value', 'test') + }) + }) + + describe('Orientation Change', () => { + it('should handle portrait orientation', () => { + cy.viewport('iphone-x', { orientation: 'portrait' }) + cy.getByTestId('main-content').should('be.visible') + }) + + it('should handle landscape orientation', () => { + cy.viewport('iphone-x', { orientation: 'landscape' }) + cy.getByTestId('main-content').should('be.visible') + }) + }) +})