import { expect, type Page, type Locator } from '@playwright/test'; import type { Flight, FlightStatus, FlightDirection } from '../../src/entities/flight/types'; import type { ScheduleEntry, ScheduleSearchParams } from '../../src/entities/schedule/types'; import type { Airport } from '../../src/entities/airport/types'; import type { Destination } from '../../src/entities/destination/types'; // ============================================================================ // Test Data Generators // ============================================================================ export const CITIES = [ { code: 'MOW', name: 'Moscow', nameRu: 'Москва' }, { code: 'LED', name: 'Saint Petersburg', nameRu: 'Санкт-Петербург' }, { code: 'AER', name: 'Sochi', nameRu: 'Сочи' }, { code: 'OVB', name: 'Novosibirsk', nameRu: 'Новосибирск' }, { code: 'KRR', name: 'Krasnodar', nameRu: 'Краснодар' }, { code: 'SVX', name: 'Yekaterinburg', nameRu: 'Екатеринбург' }, { code: 'KJA', name: 'Krasnoyarsk', nameRu: 'Красноярск' }, { code: 'GOJ', name: 'Nizhny Novgorod', nameRu: 'Нижний Новгород' }, { code: 'KUF', name: 'Samara', nameRu: 'Самара' }, { code: 'UFA', name: 'Ufa', nameRu: 'Уфа' }, { code: 'KZN', name: 'Kazan', nameRu: 'Казань' }, { code: 'ROV', name: 'Rostov-on-Don', nameRu: 'Ростов-на-Дону' }, { code: 'VVO', name: 'Vladivostok', nameRu: 'Владивосток' }, { code: 'KHV', name: 'Khabarovsk', nameRu: 'Хабаровск' }, { code: 'IKT', name: 'Irkutsk', nameRu: 'Иркутск' }, { code: 'OMS', name: 'Omsk', nameRu: 'Омск' }, { code: 'KGD', name: 'Kaliningrad', nameRu: 'Калининград' }, { code: 'MRV', name: 'Mineralnye Vody', nameRu: 'Минеральные Воды' }, { code: 'MCX', name: 'Makhachkala', nameRu: 'Махачкала' }, { code: 'AAQ', name: 'Anapa', nameRu: 'Анапа' }, ] as const; export const AIRPORTS = [ { code: 'SVO', name: 'Sheremetyevo', cityCode: 'MOW', cityName: 'Moscow', countryCode: 'RU' }, { code: 'DME', name: 'Domodedovo', cityCode: 'MOW', cityName: 'Moscow', countryCode: 'RU' }, { code: 'VKO', name: 'Vnukovo', cityCode: 'MOW', cityName: 'Moscow', countryCode: 'RU' }, { code: 'LED', name: 'Pulkovo', cityCode: 'LED', cityName: 'Saint Petersburg', countryCode: 'RU', }, { code: 'AER', name: 'Adler', cityCode: 'AER', cityName: 'Sochi', countryCode: 'RU' }, { code: 'OVB', name: 'Tolmachevo', cityCode: 'OVB', cityName: 'Novosibirsk', countryCode: 'RU' }, { code: 'KRR', name: 'Pashkovsky', cityCode: 'KRR', cityName: 'Krasnodar', countryCode: 'RU' }, { code: 'SVX', name: 'Koltsovo', cityCode: 'SVX', cityName: 'Yekaterinburg', countryCode: 'RU' }, { code: 'KJA', name: 'Emelyanovo', cityCode: 'KJA', cityName: 'Krasnoyarsk', countryCode: 'RU' }, { code: 'GOJ', name: 'Strigino', cityCode: 'GOJ', cityName: 'Nizhny Novgorod', countryCode: 'RU', }, ] as const; export const FLIGHT_NUMBERS = [ 'SU 1124', 'SU 1076', 'SU 6170', 'SU 1208', 'SU 1108', 'SU 6245', 'SU 1455', 'SU 1483', 'SU 1759', 'SU 6268', 'SU 6132', 'SU 1525', 'SU 1400', 'SU 1510', 'SU 1190', 'SU 1130', 'SU 1234', 'SU 6310', 'SU 1350', 'SU 1720', ] as const; export const AIRLINE_CODES = ['SU', 'FV'] as const; export const AIRLINE_NAMES = { SU: 'Aeroflot', FV: 'Rossiya', } as const; export const AIRCRAFT_TYPES = [ 'Airbus A320', 'Airbus A321', 'Airbus A321neo', 'Boeing 737-800', 'Boeing 777-300', 'Boeing 777-300ER', 'Sukhoi SuperJet 100', ] as const; export const STATUS_TYPES: FlightStatus[] = [ 'scheduled', 'checkin', 'boarding', 'departed', 'inFlight', 'landed', 'arrived', 'delayed', 'cancelled', 'gateChanged', ]; // ============================================================================ // Flight Data Generators // ============================================================================ export function generateFlightId(): string { return `fl-${Math.random().toString(36).substring(2, 10)}`; } export function generateFlightNumber(): string { const num = Math.floor(Math.random() * 9000) + 1000; return `SU ${num}`; } export function generateFlight({ direction = 'departure', date = new Date().toISOString().split('T')[0], cityCode = 'MOW', status = 'scheduled', flightNumber = generateFlightNumber(), airlineCode = 'SU', aircraftType = 'Airbus A320', }: { direction?: FlightDirection; date?: string; cityCode?: string; status?: FlightStatus; flightNumber?: string; airlineCode?: (typeof AIRLINE_CODES)[number]; aircraftType?: (typeof AIRCRAFT_TYPES)[number]; } = {}): Flight { const depCity = CITIES.find((c) => c.code === cityCode) || CITIES[0]; const arrCity = CITIES.find((c) => c.code !== cityCode) || CITIES[1]; const depAirport = AIRPORTS.find((a) => a.cityCode === cityCode) || AIRPORTS[0]; const arrAirport = AIRPORTS.find((a) => a.cityCode === arrCity.code && a.code !== depAirport.code) || AIRPORTS[1]; const depTime = `${Math.floor(Math.random() * 23)}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`; const arrTime = `${Math.floor(Math.random() * 23)}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`; const flightId = generateFlightId(); return { id: flightId, flightNumber, airlineCode, airlineName: AIRLINE_NAMES[airlineCode as keyof typeof AIRLINE_NAMES], aircraftType, direction, status, date, departure: { airportCode: depAirport.code, airportName: depAirport.name, cityCode: depCity.code, cityName: depCity.name, terminal: Math.random() > 0.5 ? undefined : ['A', 'B', 'C', 'D'][Math.floor(Math.random() * 4)], time: { scheduled: `${date}T${depTime}:00+03:00`, actual: status === 'departed' || status === 'inFlight' || status === 'arrived' ? `${date}T${depTime}:00+03:00` : undefined, }, }, arrival: { airportCode: arrAirport.code, airportName: arrAirport.name, cityCode: arrCity.code, cityName: arrCity.name, terminal: Math.random() > 0.5 ? undefined : ['1', '2', '3'][Math.floor(Math.random() * 3)], time: { scheduled: `${date}T${arrTime}:00+03:00`, actual: status === 'arrived' ? `${date}T${arrTime}:00+03:00` : undefined, expected: status === 'delayed' ? `${date}T${arrTime}:00+03:00` : undefined, }, }, boarding: status === 'boarding' || status === 'departed' || status === 'inFlight' ? { gate: `${Math.floor(Math.random() * 50) + 1}`, status: status === 'boarding' ? 'Идёт посадка' : 'Закончена', startTime: `${date}T${depTime}:00+03:00`, endTime: `${date}T${depTime}:00+03:00`, } : undefined, arrivalInfo: status === 'arrived' || status === 'landed' ? { baggageBelt: `${Math.floor(Math.random() * 10) + 1}`, transfer: Math.random() > 0.5 ? 'Тран' : undefined, } : undefined, checkin: status === 'checkin' || status === 'boarding' || status === 'departed' ? { status: status === 'checkin' ? 'В процессе' : 'Закончена', startTime: `${date}T${depTime}:00+03:00`, endTime: `${date}T${depTime}:00+03:00`, } : undefined, deplaning: status === 'arrived' || status === 'landed' ? { status: 'В процессе', startTime: `${date}T${arrTime}:00+03:00`, endTime: `${date}T${arrTime}:00+03:00`, transfer: Math.random() > 0.5 ? 'Трап' : undefined, gate: `${Math.floor(Math.random() * 50) + 1}`, baggageBelt: `${Math.floor(Math.random() * 10) + 1}`, } : undefined, aircraft: { type: aircraftType, name: Math.random() > 0.5 ? `${aircraftType} ${Math.floor(Math.random() * 100)}` : undefined, totalSeats: Math.floor(Math.random() * 300) + 100, economySeats: Math.floor(Math.random() * 250) + 100, businessSeats: Math.floor(Math.random() * 20) + 5, previousFlight: Math.random() > 0.5 ? generateFlightNumber() : undefined, }, catering: Math.random() > 0.5 ? { economy: true, business: true, } : undefined, schedule: { scheduledDeparture: `${date}T${depTime}:00+03:00`, scheduledArrival: `${date}T${arrTime}:00+03:00`, duration: `${Math.floor(Math.random() * 5) + 1}ч. ${Math.floor(Math.random() * 59)}мин.`, utcOffset: 'UTC+03:00', operatingDays: [1, 2, 3, 4, 5, 6, 7], weekRange: `* Расписание на неделю ${date}`, }, lastUpdated: status === 'departed' || status === 'arrived' ? `${depTime} ${date.replace(/-/g, '.')}` : undefined, }; } export function generateFlights( count: number = 20, options: Partial[0]> = {}, ): Flight[] { return Array.from({ length: count }, () => generateFlight(options)); } // ============================================================================ // Schedule Data Generators // ============================================================================ export function generateScheduleEntry({ from = 'MOW', to = 'AER', dateFrom = new Date().toISOString().split('T')[0], dateTo = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], direct = true, }: { from?: string; to?: string; dateFrom?: string; dateTo?: string; direct?: boolean; } = {}): ScheduleEntry { const depCity = CITIES.find((c) => c.code === from) || CITIES[0]; const arrCity = CITIES.find((c) => c.code === to) || CITIES[1]; const depAirport = AIRPORTS.find((a) => a.cityCode === from) || AIRPORTS[0]; const arrAirport = AIRPORTS.find((a) => a.cityCode === to) || AIRPORTS[1]; const depTime = `${Math.floor(Math.random() * 23)}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`; const arrTime = `${Math.floor(Math.random() * 23)}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`; return { id: generateFlightId(), flightNumber: generateFlightNumber(), airlineCode: 'SU', airlineName: 'Aeroflot', aircraftType: AIRCRAFT_TYPES[Math.floor(Math.random() * AIRCRAFT_TYPES.length)], departureCity: depCity.name, departureCityCode: depCity.code, departureAirport: depAirport.name, departureTime: depTime, arrivalCity: arrCity.name, arrivalCityCode: arrCity.code, arrivalAirport: arrAirport.name, arrivalTime: arrTime, daysOfWeek: [1, 2, 3, 4, 5, 6, 7], effectiveFrom: dateFrom, effectiveTo: dateTo, direct, }; } export function generateScheduleEntries( count: number = 50, options: Partial[0]> = {}, ): ScheduleEntry[] { return Array.from({ length: count }, () => generateScheduleEntry(options)); } // ============================================================================ // Destination Data Generators // ============================================================================ export function generateDestination({ departureCity = 'MOW', arrivalCity = 'AER', flightCount = Math.floor(Math.random() * 100) + 1, dates = [new Date().toISOString().split('T')[0]], }: { departureCity?: string; arrivalCity?: string; flightCount?: number; dates?: string[]; } = {}): Destination { const depCity = CITIES.find((c) => c.code === departureCity) || CITIES[0]; const arrCity = CITIES.find((c) => c.code === arrivalCity) || CITIES[1]; return { id: `dest-${departureCity}-${arrivalCity}`, departureCity: depCity.name, departureCityCode: depCity.code, arrivalCity: arrCity.name, arrivalCityCode: arrCity.code, flightCount, dates, }; } export function generateDestinations(count: number = 20): Destination[] { return Array.from({ length: count }, () => generateDestination()); } // ============================================================================ // URL Pattern Helpers // ============================================================================ export function buildRouteParam(cityCode: string, date: string): string { return `${cityCode}-${date.replace(/-/g, '')}`; } export function buildLocalePath(locale: string, path: string): string { return `/${locale}${path}`; } export function buildOnlineBoardPath( direction: 'departure' | 'arrival', cityCode: string, date: string, ): string { return `/onlineboard/${direction}/${buildRouteParam(cityCode, date)}`; } export function buildSchedulePath(): string { return '/schedule'; } export function buildFlightsMapPath(): string { return '/flights-map'; } export function buildFlightDetailsPath(flightNumber: string, date: string): string { const slug = `${flightNumber.replace(/\s+/g, '')}-${date.replace(/-/g, '')}`; return `/${slug}`; } // ============================================================================ // Test Assertion Helpers // ============================================================================ export async function expectUrlToMatch(page: Page, pattern: RegExp | string): Promise { const url = page.url(); if (typeof pattern === 'string') { expect(url).toContain(pattern); } else { expect(url).toMatch(pattern); } } export async function expectElementToBeVisible(locator: Locator, message?: string): Promise { await expect(locator).toBeVisible({ timeout: 10000 }); } export async function expectElementToBeHidden(locator: Locator, message?: string): Promise { await expect(locator).toBeHidden({ timeout: 10000 }); } export async function expectElementToHaveText( locator: Locator, text: string | RegExp, message?: string, ): Promise { await expect(locator).toHaveText(text, { timeout: 10000 }); } export async function expectElementToContainText( locator: Locator, text: string, message?: string, ): Promise { await expect(locator).toContainText(text, { timeout: 10000 }); } export async function expectElementToHaveAttribute( locator: Locator, attribute: string, value: string, message?: string, ): Promise { await expect(locator).toHaveAttribute(attribute, value, { timeout: 10000 }); } export async function expectElementToHaveClass( locator: Locator, className: string, message?: string, ): Promise { await expect(locator).toHaveClass(new RegExp(className), { timeout: 10000 }); } export async function expectElementToBeEnabled(locator: Locator, message?: string): Promise { await expect(locator).toBeEnabled({ timeout: 10000 }); } export async function expectElementToBeDisabled(locator: Locator, message?: string): Promise { await expect(locator).toBeDisabled({ timeout: 10000 }); } export async function expectElementToBeChecked(locator: Locator, message?: string): Promise { await expect(locator).toBeChecked({ timeout: 10000 }); } export async function expectElementToBeUnchecked( locator: Locator, message?: string, ): Promise { await expect(locator).not.toBeChecked({ timeout: 10000 }); } export async function expectElementToHaveValue( locator: Locator, value: string, message?: string, ): Promise { await expect(locator).toHaveValue(value, { timeout: 10000 }); } export async function expectElementToHaveCount( locator: Locator, count: number, message?: string, ): Promise { await expect(locator).toHaveCount(count, { timeout: 10000 }); } export async function expectElementToBeFocused(locator: Locator, message?: string): Promise { await expect(locator).toBeFocused({ timeout: 10000 }); } export async function expectElementNotToBeFocused( locator: Locator, message?: string, ): Promise { await expect(locator).not.toBeFocused({ timeout: 10000 }); } // ============================================================================ // Flight Search Helpers // ============================================================================ export async function searchFlightByNumber( page: Page, flightNumber: string, date?: string, ): Promise { const dateParam = date || new Date().toISOString().split('T')[0]; await page.goto(`/ru-ru/onlineboard/departure/MOW-${buildRouteParam('MOW', dateParam)}`); await page.waitForLoadState('networkidle'); const searchInput = page.locator('[data-testid="flight-search-input"]'); await searchInput.fill(flightNumber); await searchInput.press('Enter'); await page.waitForLoadState('networkidle'); } export async function searchFlightByRoute( page: Page, departureCity: string, arrivalCity: string, date?: string, ): Promise { const dateParam = date || new Date().toISOString().split('T')[0]; await page.goto(`/ru-ru/onlineboard/departure/MOW-${buildRouteParam('MOW', dateParam)}`); await page.waitForLoadState('networkidle'); const routeTab = page.locator('[data-testid="route-search-tab"]'); await routeTab.click(); await page.waitForTimeout(500); const departureInput = page.locator('[data-testid="departure-city-input"]'); const arrivalInput = page.locator('[data-testid="arrival-city-input"]'); await departureInput.fill(departureCity); await page.waitForTimeout(500); await departureInput.press('Enter'); await page.waitForTimeout(500); await arrivalInput.fill(arrivalCity); await page.waitForTimeout(500); await arrivalInput.press('Enter'); await page.waitForLoadState('networkidle'); } export async function searchFlightByDate(page: Page, date: string): Promise { await page.goto(`/ru-ru/onlineboard/departure/MOW-${buildRouteParam('MOW', date)}`); await page.waitForLoadState('networkidle'); } export async function openFlightDetails(page: Page, flightIndex: number = 0): Promise { const flightCards = page.locator('[data-testid="flight-card"]'); await expect(flightCards).toHaveCount(flightIndex + 1, { timeout: 10000 }); await flightCards.nth(flightIndex).click(); await page.waitForLoadState('networkidle'); } export async function verifyFlightCard( page: Page, flight: Flight, index: number = 0, ): Promise { const flightCards = page.locator('[data-testid="flight-card"]'); const count = await flightCards.count(); await expect(flightCards).toHaveCount(count); const card = flightCards.nth(index); await expect(card.getByText(flight.flightNumber)).toBeVisible(); await expect(card.getByText(flight.airlineName)).toBeVisible(); await expect(card.getByText(flight.departure.cityName)).toBeVisible(); await expect(card.getByText(flight.arrival.cityName)).toBeVisible(); const depTime = flight.departure.time.scheduled.slice(11, 16); await expect(card.getByText(depTime)).toBeVisible(); const arrTime = flight.arrival.time.scheduled.slice(11, 16); await expect(card.getByText(arrTime)).toBeVisible(); } export async function verifyFlightDetails(page: Page, flight: Flight): Promise { await expect(page.getByText(flight.flightNumber)).toBeVisible(); await expect(page.getByText(flight.airlineName)).toBeVisible(); await expect(page.getByText(flight.departure.cityName)).toBeVisible(); await expect(page.getByText(flight.arrival.cityName)).toBeVisible(); if (flight.aircraft?.type) { await expect(page.getByText(flight.aircraft.type)).toBeVisible(); } if (flight.schedule?.duration) { await expect(page.getByText(flight.schedule.duration)).toBeVisible(); } } // ============================================================================ // Date Helpers // ============================================================================ export function formatDateForUrl(date: string | Date): string { const d = typeof date === 'string' ? date : date.toISOString().split('T')[0]; return d.replace(/-/g, ''); } export function formatDateForDisplay(date: string | Date, locale: string = 'ru'): string { const d = typeof date === 'string' ? date : date.toISOString().split('T')[0]; const dateObj = new Date(d); const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }; return new Date(d).toLocaleDateString(locale, options); } export function getToday(): string { return new Date().toISOString().split('T')[0]; } export function getTomorrow(): string { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; } export function getYesterday(): string { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().split('T')[0]; } export function getFutureDate(days: number): string { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().split('T')[0]; } export function getPastDate(days: number): string { const d = new Date(); d.setDate(d.getDate() - days); return d.toISOString().split('T')[0]; } // ============================================================================ // Error Response Generators // ============================================================================ export function generateNotFoundError(): { status: number; body: { error: string; message: string }; } { return { status: 404, body: { error: 'Not Found', message: 'The requested resource was not found', }, }; } export function generateBadRequestError(): { status: number; body: { error: string; message: string }; } { return { status: 400, body: { error: 'Bad Request', message: 'Invalid request parameters', }, }; } export function generateUnauthorizedError(): { status: number; body: { error: string; message: string }; } { return { status: 401, body: { error: 'Unauthorized', message: 'Authentication required', }, }; } export function generateForbiddenError(): { status: number; body: { error: string; message: string }; } { return { status: 403, body: { error: 'Forbidden', message: 'Access denied', }, }; } export function generateServerError(): { status: number; body: { error: string; message: string }; } { return { status: 500, body: { error: 'Internal Server Error', message: 'An unexpected error occurred', }, }; } export function generateTimeoutError(): { status: number; body: { error: string; message: string }; } { return { status: 504, body: { error: 'Gateway Timeout', message: 'The request took too long to process', }, }; } // ============================================================================ // Async Wait Utilities (for complex async operations) // ============================================================================ /** * Wait for an element with an extended timeout for slow async operations. * Used for map initialization, calendar loading, etc. */ export async function waitForElementExtended( page: Page, selector: string, timeoutMs: number = 20000, ): Promise { try { await page.locator(selector).first().waitFor({ timeout: timeoutMs }); } catch (error) { // If element doesn't appear, log but don't fail - test will fail naturally if needed console.log(`Extended wait for "${selector}" timed out after ${timeoutMs}ms`); } } /** * Wait for a locator with extended timeout. */ export async function waitForLocatorExtended( locator: Locator, timeoutMs: number = 20000, ): Promise { try { await locator.first().waitFor({ timeout: timeoutMs, state: 'visible' }); } catch (error) { // If element doesn't appear, log but don't fail console.log(`Extended wait for locator timed out after ${timeoutMs}ms`); } } /** * Retry an async operation with backoff. * Useful for flaky async operations or race conditions. */ export async function retryAsync( fn: () => Promise, maxRetries: number = 3, delayMs: number = 500, ): Promise { let lastError: Error | undefined; for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error as Error; if (i < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1))); } } } throw lastError || new Error('Retry exhausted'); } // ============================================================================ // Test Data Fixtures // ============================================================================ export const FIXTURES = { flights: { departures: generateFlights(20, { direction: 'departure', cityCode: 'MOW' }), arrivals: generateFlights(20, { direction: 'arrival', cityCode: 'MOW' }), scheduled: generateFlights(20, { status: 'scheduled' }), departed: generateFlights(20, { status: 'departed' }), delayed: generateFlights(20, { status: 'delayed' }), cancelled: generateFlights(20, { status: 'cancelled' }), }, schedule: { entries: generateScheduleEntries(50), }, destinations: { entries: generateDestinations(20), }, airports: { entries: AIRPORTS, }, cities: { entries: CITIES, }, errors: { notFound: generateNotFoundError(), badRequest: generateBadRequestError(), unauthorized: generateUnauthorizedError(), forbidden: generateForbiddenError(), serverError: generateServerError(), timeout: generateTimeoutError(), }, }; export default FIXTURES;