20c19d15f4
Modern.js SSR intercepts all routes before any Express middleware, so the API proxy runs as a separate Express server on port 8080. Modern.js runs on 8081. The proxy uses curl subprocesses which go through the system HTTPS proxy (GOST) with a proper TLS fingerprint that the Aeroflot WAF accepts. Usage: node scripts/dev-server.mjs (replaces pnpm dev for full-stack) Also: remove stray e2e-angular test directory, fix env default to same-origin /api.
800 lines
25 KiB
TypeScript
800 lines
25 KiB
TypeScript
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<Parameters<typeof generateFlight>[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<Parameters<typeof generateScheduleEntry>[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<void> {
|
||
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<void> {
|
||
await expect(locator).toBeVisible({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeHidden(locator: Locator, message?: string): Promise<void> {
|
||
await expect(locator).toBeHidden({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToHaveText(
|
||
locator: Locator,
|
||
text: string | RegExp,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toHaveText(text, { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToContainText(
|
||
locator: Locator,
|
||
text: string,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toContainText(text, { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToHaveAttribute(
|
||
locator: Locator,
|
||
attribute: string,
|
||
value: string,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toHaveAttribute(attribute, value, { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToHaveClass(
|
||
locator: Locator,
|
||
className: string,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toHaveClass(new RegExp(className), { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeEnabled(locator: Locator, message?: string): Promise<void> {
|
||
await expect(locator).toBeEnabled({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeDisabled(locator: Locator, message?: string): Promise<void> {
|
||
await expect(locator).toBeDisabled({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeChecked(locator: Locator, message?: string): Promise<void> {
|
||
await expect(locator).toBeChecked({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeUnchecked(
|
||
locator: Locator,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).not.toBeChecked({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToHaveValue(
|
||
locator: Locator,
|
||
value: string,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toHaveValue(value, { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToHaveCount(
|
||
locator: Locator,
|
||
count: number,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).toHaveCount(count, { timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementToBeFocused(locator: Locator, message?: string): Promise<void> {
|
||
await expect(locator).toBeFocused({ timeout: 10000 });
|
||
}
|
||
|
||
export async function expectElementNotToBeFocused(
|
||
locator: Locator,
|
||
message?: string,
|
||
): Promise<void> {
|
||
await expect(locator).not.toBeFocused({ timeout: 10000 });
|
||
}
|
||
|
||
// ============================================================================
|
||
// Flight Search Helpers
|
||
// ============================================================================
|
||
|
||
export async function searchFlightByNumber(
|
||
page: Page,
|
||
flightNumber: string,
|
||
date?: string,
|
||
): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<T>(
|
||
fn: () => Promise<T>,
|
||
maxRetries: number = 3,
|
||
delayMs: number = 500,
|
||
): Promise<T> {
|
||
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;
|