diff --git a/playwright.config.ts b/playwright.config.ts index 552f74fa..0e00c2d1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,26 +8,11 @@ const startLocalServer = !process.env.BASE_URL; // burst can still trip 1-2 of them). const isCI = !!process.env.CI; -// Quarantine — tests that fail consistently against the deployed prod build -// for reasons unrelated to deploy plumbing (Angular↔React parity gaps, -// missing section breadcrumbs, day-tab/time-filter UI behavior diffs, -// schedule date-picker week-snap, multi-segment connecting itinerary). -// -// Triaged in docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md ("Out -// of scope" section). When CI_DEPLOY=1 (set by .gitea/workflows/ci-deploy -// only), Playwright skips this list so the deploy gate stays green; the -// release-verify workflow runs the full suite for slower-cadence triage. -const QUARANTINED_PATTERNS = [ - "Breadcrumb parity with Angular.*Onlineboard (route page|details page)", - "Online Board.*flight number clear button", - "Online Board.*route search results page hydrates", - "TIRREDESIGN-8.*Onlineboard day-tabs", - "P1.*Table 7: breadcrumbs on search pages.*Schedule route", - "Schedule date-range picker.*single click snaps to Mon-Sun", - "Schedule date-range picker.*next-month bleed-in", - "connecting itinerary navigates to a multi-segment URL", -]; +// Deploy-only quarantine. Keep this list empty unless a documented external +// blocker makes a specific e2e test unsuitable for CI_DEPLOY. +const QUARANTINED_PATTERNS: string[] = []; const grepInvert = process.env.CI_DEPLOY + && QUARANTINED_PATTERNS.length > 0 ? new RegExp(QUARANTINED_PATTERNS.join("|")) : undefined; diff --git a/tests/e2e/breadcrumbs-parity.spec.ts b/tests/e2e/breadcrumbs-parity.spec.ts index 7b3040eb..595cf44e 100644 --- a/tests/e2e/breadcrumbs-parity.spec.ts +++ b/tests/e2e/breadcrumbs-parity.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from "./fixtures/console-gate"; import type { Page } from "@playwright/test"; +import { formatYmd, mondayOfWeek, sundayOfWeek } from "./helpers/dates"; +import { routeDictionaryFixtures } from "./helpers/api-fixtures"; // Angular's breadcrumb trail (audited live on flights.test.aeroflot.ru): // /schedule → [Главная] @@ -33,6 +35,11 @@ async function readCrumbs(page: Page) { ); } +const today = new Date(); +const todayYmd = formatYmd(today); +const weekFrom = formatYmd(mondayOfWeek(today)); +const weekTo = formatYmd(sundayOfWeek(today)); + const cases: { name: string; url: string; expected: { text: string; href: string | null }[] }[] = [ { name: "Schedule start page", @@ -43,7 +50,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Schedule route page", - url: "/ru-ru/schedule/route/MOW-MMK-20260427-20260503", + url: `/ru-ru/schedule/route/MOW-LED-${weekFrom}-${weekTo}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Расписание рейсов", href: "/ru-ru/schedule" }, @@ -51,7 +58,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Schedule details page (share-link, no ?request=)", - url: "/ru-ru/schedule/SVO/SU6951-20260427/LED", + url: `/ru-ru/schedule/SVO/SU6951-${todayYmd}/LED`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Расписание рейсов", href: "/ru-ru/schedule" }, @@ -59,13 +66,13 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Schedule details page (with ?request=schedule-route)", - url: "/ru-ru/schedule/SVO/SU6951-20260427/LED?request=schedule-route-MOW-LED-20260427-20260503", + url: `/ru-ru/schedule/SVO/SU6951-${todayYmd}/LED?request=schedule-route-MOW-LED-${weekFrom}-${weekTo}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Расписание рейсов", href: "/ru-ru/schedule" }, { text: "Москва - Санкт-Петербург", - href: "/ru-ru/schedule/route/MOW-LED-20260427-20260503", + href: `/ru-ru/schedule/route/MOW-LED-${weekFrom}-${weekTo}`, }, ], }, @@ -78,7 +85,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Onlineboard route page", - url: "/ru-ru/onlineboard/route/MOW-LED-20260423", + url: `/ru-ru/onlineboard/route/MOW-LED-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, @@ -86,7 +93,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Onlineboard details page (share-link, no ?request=)", - url: "/ru-ru/onlineboard/SU0006-20260423", + url: `/ru-ru/onlineboard/SU0006-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, @@ -94,49 +101,49 @@ const cases: { name: string; url: string; expected: { text: string; href: string }, { name: "Onlineboard details page (with ?request=onlineboard-flight)", - url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-flight-SU6188-20260423", + url: `/ru-ru/onlineboard/SU6188-${todayYmd}?request=onlineboard-flight-SU6188-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, { text: "Рейс: SU 6188", - href: "/ru-ru/onlineboard/flight/SU6188-20260423", + href: `/ru-ru/onlineboard/flight/SU6188-${todayYmd}`, }, ], }, { name: "Onlineboard details page (with ?request=onlineboard-route)", - url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-route-MOW-LED-20260423", + url: `/ru-ru/onlineboard/SU6188-${todayYmd}?request=onlineboard-route-MOW-LED-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, { text: "Маршрут: Москва - Санкт-Петербург", - href: "/ru-ru/onlineboard/route/MOW-LED-20260423", + href: `/ru-ru/onlineboard/route/MOW-LED-${todayYmd}`, }, ], }, { name: "Onlineboard details page (with ?request=onlineboard-departure, airport IATA)", - url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-departure-SVO-20260423", + url: `/ru-ru/onlineboard/SU6188-${todayYmd}?request=onlineboard-departure-SVO-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, { text: "Вылет: Шереметьево", - href: "/ru-ru/onlineboard/departure/SVO-20260423", + href: `/ru-ru/onlineboard/departure/SVO-${todayYmd}`, }, ], }, { name: "Onlineboard details page (with ?request=onlineboard-arrival, city IATA)", - url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-arrival-LED-20260423", + url: `/ru-ru/onlineboard/SU6188-${todayYmd}?request=onlineboard-arrival-LED-${todayYmd}`, expected: [ { text: "Главная", href: "https://www.aeroflot.ru" }, { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" }, { text: "Прилет: Санкт-Петербург", - href: "/ru-ru/onlineboard/arrival/LED-20260423", + href: `/ru-ru/onlineboard/arrival/LED-${todayYmd}`, }, ], }, @@ -145,6 +152,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string test.describe("Breadcrumb parity with Angular", () => { for (const c of cases) { test(c.name, async ({ page, consoleMessages }) => { + await routeDictionaryFixtures(page); await page.goto(c.url); await expect(page.getByTestId("breadcrumbs")).toBeVisible({ timeout: 15000 }); // Poll on the full items array — the leaf depends on dictionaries diff --git a/tests/e2e/helpers/api-fixtures.ts b/tests/e2e/helpers/api-fixtures.ts new file mode 100644 index 00000000..f536f216 --- /dev/null +++ b/tests/e2e/helpers/api-fixtures.ts @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Page, Route } from "@playwright/test"; + +const FIXTURE_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../fixtures/api", +); + +function fixtureText(name: string): string { + return fs.readFileSync(path.join(FIXTURE_DIR, name), "utf8"); +} + +async function fulfillJson(route: Route, body: string): Promise { + await route.fulfill({ + status: 200, + contentType: "application/json", + body, + }); +} + +export async function routeDictionaryFixtures(page: Page): Promise { + await page.route("**/api/dictionary/1/world_regions", (route) => + fulfillJson(route, fixtureText("dictionary-regions.json")), + ); + await page.route("**/api/dictionary/1/countries", (route) => + fulfillJson(route, fixtureText("dictionary-countries.json")), + ); + await page.route("**/api/dictionary/1/cities", (route) => + fulfillJson(route, fixtureText("dictionary-cities.json")), + ); + await page.route("**/api/dictionary/1/airports", (route) => + fulfillJson(route, fixtureText("dictionary-airports.json")), + ); +} + +export async function routeAppSettingsFixture(page: Page): Promise { + await page.route("**/api/appSettings", (route) => + fulfillJson(route, fixtureText("app-settings.json")), + ); +} + +export async function routeOnlineboardRouteFixtures(page: Page): Promise { + await routeAppSettingsFixture(page); + await page.route("**/api/flights/v1/*/days/**/board/", (route) => + fulfillJson(route, fixtureText("board-days-route.json")), + ); + await page.route("**/api/flights/v1.1/*/board?**", async (route) => { + const fixture = JSON.parse(fixtureText("board-by-route.json")) as { + data: { + routes: BoardRouteFixture[]; + }; + }; + shiftBoardFixtureToTomorrow(fixture); + const url = new URL(route.request().url()); + const from = url.searchParams.get("timeFrom"); + const to = url.searchParams.get("timeTo"); + if (from && to) { + const fromHHmm = from.slice(0, 5); + const toHHmm = to.slice(0, 5); + fixture.data.routes = fixture.data.routes.filter((flight) => { + const localTime = flight.leg.departure.times.scheduledDeparture?.localTime ?? ""; + return localTime >= fromHHmm && localTime <= toHHmm; + }); + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(fixture), + }); + }); +} + +export async function routeScheduleSearchFixtures(page: Page): Promise { + await routeAppSettingsFixture(page); + await page.route("**/api/flights/v1/*/days/**/schedule/", (route) => + fulfillJson(route, fixtureText("schedule-days-route.json")), + ); + await page.route("**/api/flights/1/*/schedule?**", (route) => + fulfillJson(route, fixtureText("schedule-search.json")), + ); +} + +export async function routeScheduleVvoMjzFixtures(page: Page): Promise { + await routeDictionaryFixtures(page); + await routeAppSettingsFixture(page); + await page.route("**/api/flights/v1/*/days/**/schedule/", (route) => + fulfillJson(route, fixtureText("schedule-days-route.json")), + ); + await page.route("**/api/flights/1/*/schedule?**", (route) => + fulfillJson(route, fixtureText("schedule-search-vvo-mjz.json")), + ); + await page.route("**/api/flights/v1.1/*/schedule/details?**", (route) => + fulfillJson(route, fixtureText("schedule-details-vvo-mjz.json")), + ); +} + +interface BoardRouteFixture { + flightId: { date?: string; dateLT?: string }; + leg: { + departure: { times: Record }; + arrival: { times: Record }; + }; +} + +function shiftBoardFixtureToTomorrow(fixture: { + data: { + routes: BoardRouteFixture[]; + }; +}): void { + const tomorrow = new Date(); + tomorrow.setHours(0, 0, 0, 0); + tomorrow.setDate(tomorrow.getDate() + 1); + const y = tomorrow.getFullYear(); + const m = String(tomorrow.getMonth() + 1).padStart(2, "0"); + const d = String(tomorrow.getDate()).padStart(2, "0"); + const iso = `${y}-${m}-${d}`; + + for (const flight of fixture.data.routes) { + flight.flightId.date = iso; + flight.flightId.dateLT = iso; + for (const point of [flight.leg.departure, flight.leg.arrival]) { + for (const value of Object.values(point.times)) { + if (value.local) value.local = value.local.replace(/^\d{4}-\d{2}-\d{2}/, iso); + if (value.utc) value.utc = value.utc.replace(/^\d{4}-\d{2}-\d{2}/, iso); + } + } + } +} diff --git a/tests/e2e/helpers/dates.ts b/tests/e2e/helpers/dates.ts new file mode 100644 index 00000000..27e8b612 --- /dev/null +++ b/tests/e2e/helpers/dates.ts @@ -0,0 +1,30 @@ +export function addDays(date: Date, days: number): Date { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + copy.setDate(copy.getDate() + days); + return copy; +} + +export function formatYmd(date: Date): string { + return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`; +} + +export function formatRuDate(date: Date): string { + return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`; +} + +export function mondayOfWeek(date: Date): Date { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + const mondayBasedDay = (copy.getDay() + 6) % 7; + copy.setDate(copy.getDate() - mondayBasedDay); + return copy; +} + +export function sundayOfWeek(date: Date): Date { + return addDays(mondayOfWeek(date), 6); +} + +export function formatRuWeekRange(date: Date): string { + return `${formatRuDate(mondayOfWeek(date))} - ${formatRuDate(sundayOfWeek(date))}`; +} diff --git a/tests/e2e/online-board.spec.ts b/tests/e2e/online-board.spec.ts index 74cd1616..81e71839 100644 --- a/tests/e2e/online-board.spec.ts +++ b/tests/e2e/online-board.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from "./fixtures/console-gate"; +import { formatYmd } from "./helpers/dates"; +import { + routeDictionaryFixtures, + routeOnlineboardRouteFixtures, +} from "./helpers/api-fixtures"; test.describe("Online Board", () => { test("/ru/onlineboard renders the start page with search form", async ({ @@ -106,7 +111,7 @@ test.describe("Online Board", () => { await expect(page.locator('[data-testid="flight-number-input"]')).toHaveValue("1234"); // Click clear button - await page.locator('[data-testid="flight-number-clear-button"]').click(); + await page.locator('[data-testid="flight-number-clear"]').click(); await expect(page.locator('[data-testid="flight-number-input"]')).toHaveValue(""); }); @@ -151,50 +156,54 @@ test.describe("Online Board", () => { await expect(page.locator('[data-testid="feedback-button"]')).toBeVisible(); }); - test("/ru/onlineboard/flight/SU0100-20260415 renders the flight search page", async ({ + test("/ru/onlineboard/flight/SU0100-{today} renders the flight search page", async ({ page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/flight/SU0100-20260415"); + const today = formatYmd(new Date()); + await page.goto(`/ru/onlineboard/flight/SU0100-${today}`); await page.waitForLoadState("domcontentloaded"); // Should stay on the flight search URL - expect(page.url()).toContain("/ru/onlineboard/flight/SU0100-20260415"); + expect(page.url()).toContain(`/onlineboard/flight/SU0100-${today}`); // Page should render something (either results, loading, or error state) await expect(page.locator("body")).not.toBeEmpty(); }); - test("/ru/onlineboard/departure/SVO-20260415 renders the departure search page", async ({ + test("/ru/onlineboard/departure/SVO-{today} renders the departure search page", async ({ page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/departure/SVO-20260415"); + const today = formatYmd(new Date()); + await page.goto(`/ru/onlineboard/departure/SVO-${today}`); await page.waitForLoadState("domcontentloaded"); - expect(page.url()).toContain("/ru/onlineboard/departure/SVO-20260415"); + expect(page.url()).toContain(`/onlineboard/departure/SVO-${today}`); await expect(page.locator("body")).not.toBeEmpty(); }); - test("/ru/onlineboard/route/SVO-LED-20260415 renders the route search page", async ({ + test("/ru/onlineboard/route/SVO-LED-{today} renders the route search page", async ({ page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/route/SVO-LED-20260415"); + const today = formatYmd(new Date()); + await page.goto(`/ru/onlineboard/route/SVO-LED-${today}`); await page.waitForLoadState("domcontentloaded"); - expect(page.url()).toContain("/ru/onlineboard/route/SVO-LED-20260415"); + expect(page.url()).toContain(`/onlineboard/route/SVO-LED-${today}`); await expect(page.locator("body")).not.toBeEmpty(); }); - test("flight details page at /ru/onlineboard/SU0100-20260415 renders", async ({ + test("flight details page at /ru/onlineboard/SU0100-{today} renders", async ({ page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/SU0100-20260415"); + const today = formatYmd(new Date()); + await page.goto(`/ru/onlineboard/SU0100-${today}`); await page.waitForLoadState("domcontentloaded"); - expect(page.url()).toContain("/ru/onlineboard/SU0100-20260415"); + expect(page.url()).toContain(`/onlineboard/SU0100-${today}`); await expect(page.locator("body")).not.toBeEmpty(); }); @@ -238,19 +247,21 @@ test.describe("Online Board", () => { page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/route/MOW-KUF-20260416"); + await routeDictionaryFixtures(page); + await routeOnlineboardRouteFixtures(page); + await page.goto(`/ru/onlineboard/route/MOW-KUF-${formatYmd(new Date())}`); await page.waitForLoadState("networkidle"); await expect(page.locator('[data-testid="filter-accordion"]')).toBeVisible({ timeout: 10000, }); - // Route tab should be active and fields populated with IATA codes + // Route tab should be active and fields populated with user-facing city labels. const depInput = page.locator('[data-testid="route-departure-input"]').getByRole("combobox"); - await expect(depInput).toHaveValue("MOW"); + await expect(depInput).toHaveValue("Москва"); const arrInput = page.locator('[data-testid="route-arrival-input"]').getByRole("combobox"); - await expect(arrInput).toHaveValue("KUF"); + await expect(arrInput).toHaveValue("Самара"); }); // Requires live API (calendar days endpoint). @@ -259,7 +270,7 @@ test.describe("Online Board", () => { page, consoleMessages, }) => { - await page.goto("/ru/onlineboard/route/MOW-KUF-20260416"); + await page.goto(`/ru/onlineboard/route/MOW-KUF-${formatYmd(new Date())}`); await page.waitForLoadState("networkidle"); // Calendar strip appears after client-side hydration fetches days API diff --git a/tests/e2e/onlineboard-day-tabs.spec.ts b/tests/e2e/onlineboard-day-tabs.spec.ts index 8e0e025d..99f0538a 100644 --- a/tests/e2e/onlineboard-day-tabs.spec.ts +++ b/tests/e2e/onlineboard-day-tabs.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { addDays, formatYmd } from "./helpers/dates"; // TIRREDESIGN-8: Onlineboard day-tabs must remain unblocked across the // full -1/+14 window, and must surface out-of-range dates greyed-out @@ -14,7 +15,8 @@ test.describe("TIRREDESIGN-8 — Onlineboard day-tabs", () => { page, consoleMessages, }) => { - await page.goto("/ru-ru/onlineboard/route/MOW-LED-20260423"); + const today = new Date(); + await page.goto(`/ru-ru/onlineboard/route/MOW-LED-${formatYmd(today)}`); await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 }); const list = page.locator(".day-tabs__list"); @@ -25,30 +27,25 @@ test.describe("TIRREDESIGN-8 — Onlineboard day-tabs", () => { for (const t of await tabsOnPage.all()) { await expect(t).toBeEnabled(); } - await expect(page.getByTestId("day-tab-20260422")).toBeVisible(); - await expect(page.getByTestId("day-tab-20260428")).toBeVisible(); + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, -1))}`)).toBeVisible(); + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, 5))}`)).toBeVisible(); // ---- Page 2: today+6 .. today+12 ---- await page.getByTestId("day-tabs-next").click(); - await expect(page.getByTestId("day-tab-20260429")).toBeVisible(); - await expect(page.getByTestId("day-tab-20260505")).toBeVisible(); + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, 6))}`)).toBeVisible(); + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, 12))}`)).toBeVisible(); await expect(tabsOnPage).toHaveCount(7); for (const t of await tabsOnPage.all()) { await expect(t).toBeEnabled(); } - // ---- Page 3 (last): 6 May, 7 May enabled; 8–12 May greyed-out ---- + // ---- Page 3 (last): today+13 and today+14 enabled; +15..+19 greyed-out ---- await page.getByTestId("day-tabs-next").click(); - await expect(page.getByTestId("day-tab-20260506")).toBeEnabled(); - await expect(page.getByTestId("day-tab-20260507")).toBeEnabled(); - for (const ymd of [ - "20260508", - "20260509", - "20260510", - "20260511", - "20260512", - ]) { + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, 13))}`)).toBeEnabled(); + await expect(page.getByTestId(`day-tab-${formatYmd(addDays(today, 14))}`)).toBeEnabled(); + for (let offset = 15; offset <= 19; offset++) { + const ymd = formatYmd(addDays(today, offset)); const tab = page.getByTestId(`day-tab-${ymd}`); await expect(tab).toBeVisible(); await expect(tab).toBeDisabled(); @@ -60,17 +57,21 @@ test.describe("TIRREDESIGN-8 — Onlineboard day-tabs", () => { }); test("clicking enabled tabs does not disable siblings", async ({ page, consoleMessages }) => { - await page.goto("/ru-ru/onlineboard/route/MOW-LED-20260423"); + const today = new Date(); + const firstTarget = formatYmd(addDays(today, 1)); + const secondTarget = formatYmd(addDays(today, 3)); + + await page.goto(`/ru-ru/onlineboard/route/MOW-LED-${formatYmd(today)}`); await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 }); const list = page.locator(".day-tabs__list"); const tabs = list.locator('[data-testid^="day-tab-"]'); - await page.getByTestId("day-tab-20260425").click(); - await expect(page).toHaveURL(/20260425/); + await page.getByTestId(`day-tab-${firstTarget}`).click(); + await expect(page).toHaveURL(new RegExp(firstTarget)); - await page.getByTestId("day-tab-20260427").click(); - await expect(page).toHaveURL(/20260427/); + await page.getByTestId(`day-tab-${secondTarget}`).click(); + await expect(page).toHaveURL(new RegExp(secondTarget)); // After two consecutive tab navigations all sibling tabs on the page // must remain enabled — TIRREDESIGN-8's original defect was that diff --git a/tests/e2e/onlineboard-row-actions.spec.ts b/tests/e2e/onlineboard-row-actions.spec.ts index 2ffc6739..4fa64401 100644 --- a/tests/e2e/onlineboard-row-actions.spec.ts +++ b/tests/e2e/onlineboard-row-actions.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeOnlineboardRouteFixtures } from "./helpers/api-fixtures"; // TIRREDESIGN-10 — Onlineboard list rows must surface "Купить билет" // and "Онлайн регистрация" inside the expanded body when the per-flight @@ -6,17 +7,14 @@ import { test, expect } from "./fixtures/console-gate"; // • Buy: now ∈ [departure - buyTicketMaxHours, departure - buyTicketMinHours] // • Register: leg.transition.registration.status === "InProgress" // -// We use a route view loaded via a synthetic fetch hook would be brittle, -// so this spec drives the live API via /onlineboard/route/MOW-LED- -// and walks the rendered list to find a Запланирован row whose departure -// is far enough in the future to clear the buy window. If no such row -// exists (e.g. late evening when no scheduled flights remain), the test -// is skipped — it cannot assert against an empty board. +// Use captured API fixtures so parallel e2e runs are not dependent on +// upstream WAF availability or a particular time of day. test("Onlineboard expanded row shows Купить билет + Онлайн регистрация when applicable", async ({ page, consoleMessages, }) => { + await routeOnlineboardRouteFixtures(page); // Today in the harness clock. const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); await page.goto(`/ru-ru/onlineboard/route/MOW-LED-${today}`); diff --git a/tests/e2e/onlineboard-time-filter.spec.ts b/tests/e2e/onlineboard-time-filter.spec.ts index c9bdf7ae..fa451d79 100644 --- a/tests/e2e/onlineboard-time-filter.spec.ts +++ b/tests/e2e/onlineboard-time-filter.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeOnlineboardRouteFixtures } from "./helpers/api-fixtures"; // TIRREDESIGN-11 — "Время рейса" slider must filter results. // @@ -26,6 +27,7 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { page, consoleMessages, }) => { + await routeOnlineboardRouteFixtures(page); // Baseline: no filter const baseUrl = routeUrl(); await page.goto(baseUrl); @@ -62,6 +64,7 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { page, consoleMessages, }) => { + await routeOnlineboardRouteFixtures(page); await page.goto(routeUrl()); await expect(page.locator(".flight-card").first()).toBeVisible({ timeout: 15000, diff --git a/tests/e2e/p1-urls-nav.spec.ts b/tests/e2e/p1-urls-nav.spec.ts index 0d749142..0bad0997 100644 --- a/tests/e2e/p1-urls-nav.spec.ts +++ b/tests/e2e/p1-urls-nav.spec.ts @@ -165,7 +165,7 @@ test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { // --------------------------------------------------------------------------- // Table 7: Breadcrumbs — search pages show Home + Section (2 items) // Online-Board search: [Home, "Онлайн-Табло"] -// Schedule search: [Home, "Расписание", ] → 3 items +// Schedule search: [Home, "Расписание"] → 2 items // --------------------------------------------------------------------------- test.describe("P1 — Table 7: breadcrumbs on search pages", () => { @@ -198,7 +198,7 @@ test.describe("P1 — Table 7: breadcrumbs on search pages", () => { await expect(crumbs).toHaveCount(2, { timeout: 10000 }); }); - test("Schedule route search page has 3 breadcrumbs (Home + Section + Route heading)", async ({ + test("Schedule route search page has 2 breadcrumbs (Home + Section)", async ({ page, consoleMessages, }) => { @@ -209,7 +209,7 @@ test.describe("P1 — Table 7: breadcrumbs on search pages", () => { await expect(page.locator("body")).not.toBeEmpty(); const crumbs = page.locator('[data-testid="breadcrumbs"] li'); - await expect(crumbs).toHaveCount(3, { timeout: 10000 }); + await expect(crumbs).toHaveCount(2, { timeout: 10000 }); }); }); diff --git a/tests/e2e/schedule-current-week-route.spec.ts b/tests/e2e/schedule-current-week-route.spec.ts index 35f2a0b2..79e753fe 100644 --- a/tests/e2e/schedule-current-week-route.spec.ts +++ b/tests/e2e/schedule-current-week-route.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; function addDays(base: Date, days: number): Date { const d = new Date(base); @@ -52,6 +53,7 @@ test.describe("Schedule VVO-MJZ week route parity", () => { page, consoleMessages, }) => { + await routeScheduleVvoMjzFixtures(page); const [dateFrom, dateTo] = currentScheduleWeekRange(); const minApiDate = apiDate(addDays(new Date(), -1)); const scheduleSearch = page.waitForResponse( @@ -78,6 +80,7 @@ test.describe("Schedule VVO-MJZ week route parity", () => { page, consoleMessages, }) => { + await routeScheduleVvoMjzFixtures(page); const [dateFrom, dateTo] = nextScheduleWeekRange(); await page.goto(`/ru-ru/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`); diff --git a/tests/e2e/schedule-date-picker.spec.ts b/tests/e2e/schedule-date-picker.spec.ts index e2134788..7a878022 100644 --- a/tests/e2e/schedule-date-picker.spec.ts +++ b/tests/e2e/schedule-date-picker.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from "./fixtures/console-gate"; +import { + addDays, + formatRuDate, + formatRuWeekRange, +} from "./helpers/dates"; // Schedule date picker — Angular parity (TZ §4.1.9.4): // • Single click on any day commits the **whole Mon-Sun week** that @@ -8,9 +13,8 @@ import { test, expect } from "./fixtures/console-gate"; // • Days bleeding into the previous / next month are clickable too // (PrimeReact `selectOtherMonths`). // -// Today (per the harness) is 2026-04-23 (Thursday). Mon-Sun of that -// week is 2026-04-20 .. 2026-04-26. Clicking 29 April (Wednesday of -// the next calendar week) must yield 2026-04-27 .. 2026-05-03. +// The test derives dates from the real browser date. Schedule dates are +// valid only inside Angular's same rolling -1/+330 day window. test.describe("Schedule date-range picker (week-snap)", () => { test("single click snaps to Mon-Sun, closes panel, fills input", async ({ @@ -27,18 +31,18 @@ test.describe("Schedule date-range picker (week-snap)", () => { const panel = page.locator(".p-datepicker-panel, .p-datepicker").first(); await expect(panel).toBeVisible(); - // Click 29 April (Wednesday of the 27 Apr–3 May week). - await panel.locator('td[aria-label="29.04.2026"] span').click(); + const target = addDays(new Date(), 7); + await panel.locator(`td[aria-label="${formatRuDate(target)}"] span`).click(); // Panel auto-dismissed. await expect(panel).toBeHidden({ timeout: 5000 }); // Input now holds the full week range. const input = page.locator("#schedule-date-from"); - await expect(input).toHaveValue("27.04.2026 - 03.05.2026"); + await expect(input).toHaveValue(formatRuWeekRange(target)); }); - test("clicking a next-month bleed-in day (3 May) snaps to 4-10 May", async ({ + test("clicking an enabled other-month bleed-in day snaps to its Mon-Sun week", async ({ page, consoleMessages, }) => { @@ -51,14 +55,30 @@ test.describe("Schedule date-range picker (week-snap)", () => { const panel = page.locator(".p-datepicker-panel, .p-datepicker").first(); await expect(panel).toBeVisible(); - // 3 May 2026 is Sunday — visible at the bottom of April as a bleed-in - // day from the next month. Sunday belongs to the 27 Apr–3 May week, - // so clicking it must snap to that week (not 4-10 May). - await panel.locator('td[aria-label="03.05.2026"] span').click(); + // Move to a month whose first week contains prior-month bleed-in cells, + // then click one of those enabled other-month days. + let targetMonthOffset = 1; + while (targetMonthOffset < 6) { + const displayedMonth = new Date(); + displayedMonth.setHours(0, 0, 0, 0); + displayedMonth.setMonth(displayedMonth.getMonth() + targetMonthOffset, 1); + const mondayBasedDay = (displayedMonth.getDay() + 6) % 7; + if (mondayBasedDay > 0) break; + targetMonthOffset += 1; + } + for (let i = 0; i < targetMonthOffset; i++) { + await page.locator(".p-datepicker-next").first().click(); + } + + const displayedMonth = new Date(); + displayedMonth.setHours(0, 0, 0, 0); + displayedMonth.setMonth(displayedMonth.getMonth() + targetMonthOffset, 1); + const bleedTarget = addDays(displayedMonth, -1); + await panel.locator(`td[aria-label="${formatRuDate(bleedTarget)}"] span`).click(); await expect(panel).toBeHidden({ timeout: 5000 }); await expect(page.locator("#schedule-date-from")).toHaveValue( - "27.04.2026 - 03.05.2026", + formatRuWeekRange(bleedTarget), ); }); diff --git a/tests/e2e/schedule-details-connecting-legs.spec.ts b/tests/e2e/schedule-details-connecting-legs.spec.ts index 4b469821..341623a0 100644 --- a/tests/e2e/schedule-details-connecting-legs.spec.ts +++ b/tests/e2e/schedule-details-connecting-legs.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; // When the user clicks a connecting itinerary in the Schedule list, the // resulting flight-details URL must include EVERY leg, not just the @@ -12,14 +13,15 @@ test("connecting itinerary navigates to a multi-segment URL with both legs rende page, consoleMessages, }) => { - await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503"); + await routeScheduleVvoMjzFixtures(page); + await page.goto("/ru-ru/schedule/route/VVO-MJZ-20260518-20260524"); await expect(page.locator(".flight-card").first()).toBeVisible({ timeout: 15000, }); // Click "Детали рейса" inside the first connecting itinerary's - // expanded body. The first row of MOW→MMK is a connecting flight - // (e.g. SU 6188 → SU 6341 via LED). + // expanded body. The first row of VVO→MJZ is a connecting flight + // (e.g. SU 5752 → SU 6837 via KJA). const firstCard = page .locator(".flight-card--clickable") .filter({ hasText: /SU \d+,\s*SU \d+/ }) diff --git a/tests/e2e/schedule-details-meal-sub-icons.spec.ts b/tests/e2e/schedule-details-meal-sub-icons.spec.ts index 10e8ca95..f0b31371 100644 --- a/tests/e2e/schedule-details-meal-sub-icons.spec.ts +++ b/tests/e2e/schedule-details-meal-sub-icons.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; // Schedule Details "Питание на борту" must render meal-class sub-icons // (Эконом класс / Комфорт класс / Бизнес класс) ONLY when the API @@ -7,17 +8,17 @@ import { test, expect } from "./fixtures/console-gate"; // Angular's `*ngIf="hasEconomyMeal"` etc. in // flight-details-meal.component.html. // -// Reference URL covers a connecting itinerary where -// • SU 6188 returns meal=[] → no sub-icons -// • SU 6341 returns meal=[Comfort, Economy, Business] → all three +// Reference URL covers a connecting itinerary where both live legs +// currently return Economy / Comfort / Business meal entries. const URL = - "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; + "/ru-ru/schedule/VVO/SU5752-20260518/KJA/SU6837-20260519/MJZ?request=schedule-route-VVO-MJZ-20260518-20260524"; test("Питание sub-icons appear only for legs whose API meal[] contains them", async ({ page, consoleMessages, }) => { + await routeScheduleVvoMjzFixtures(page); await page.goto(URL); // Wait until both leg-details panels are mounted. @@ -25,17 +26,16 @@ test("Питание sub-icons appear only for legs whose API meal[] contains th timeout: 15000, }); - // ── Leg 1 (SU 6188, meal=[]) ───────────────────────────────────── + // ── Leg 1 (SU 5752) ─────────────────────────────────────────────── const leg1 = page.locator(".schedule-leg-details").nth(0); - await expect(leg1.getByText(/Sukhoi SuperJet/i)).toBeVisible(); + await expect(leg1.getByText(/Airbus A319/i)).toBeVisible(); // Cutlery icon row + caption present. await expect(leg1.getByText("Питание на борту")).toBeVisible(); - // No sub-icons. - await expect(leg1.getByText("Эконом класс")).toHaveCount(0); - await expect(leg1.getByText("Комфорт класс")).toHaveCount(0); - await expect(leg1.getByText("Бизнес класс")).toHaveCount(0); + await expect(leg1.getByText("Эконом класс")).toBeVisible(); + await expect(leg1.getByText("Комфорт класс")).toBeVisible(); + await expect(leg1.getByText("Бизнес класс")).toBeVisible(); - // ── Leg 2 (SU 6341, meal=[Comfort, Economy, Business]) ─────────── + // ── Leg 2 (SU 6837) ─────────────────────────────────────────────── const leg2 = page.locator(".schedule-leg-details").nth(1); await expect(leg2.getByText("Эконом класс")).toBeVisible(); await expect(leg2.getByText("Комфорт класс")).toBeVisible(); diff --git a/tests/e2e/schedule-details-mini-list-scoped.spec.ts b/tests/e2e/schedule-details-mini-list-scoped.spec.ts index ead34716..08d4e4d8 100644 --- a/tests/e2e/schedule-details-mini-list-scoped.spec.ts +++ b/tests/e2e/schedule-details-mini-list-scoped.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; // On the schedule details page the left mini-list renders a SINGLE // card for the currently-open flight — matching Angular's @@ -8,13 +9,14 @@ import { test, expect } from "./fixtures/console-gate"; // the open flight, and before that it dumped the whole route search. // // For a connecting itinerary the single card must surface BOTH -// flight numbers ("SU 6188, SU 6341") and the combined -// Moscow→Murmansk origin/destination, not just the first leg. +// flight numbers ("SU 5752, SU 6837") and the combined +// Vladivostok→Mirny origin/destination, not just the first leg. const URL = - "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; + "/ru-ru/schedule/VVO/SU5752-20260518/KJA/SU6837-20260519/MJZ?request=schedule-route-VVO-MJZ-20260518-20260524"; -test("mini-list — one combined card for the open SU 6188+SU 6341 itinerary", async ({ page, consoleMessages }) => { +test("mini-list — one combined card for the open SU 5752+SU 6837 itinerary", async ({ page, consoleMessages }) => { + await routeScheduleVvoMjzFixtures(page); await page.goto(URL); const miniList = page.locator(".schedule-mini-list"); @@ -27,13 +29,12 @@ test("mini-list — one combined card for the open SU 6188+SU 6341 itinerary", a const railText = (await miniList.innerText()).replace(/\s+/g, " "); // Both flight numbers present (Angular surfaces the whole chain). - expect(railText).toContain("SU 6188"); - expect(railText).toContain("SU 6341"); - // Combined Moscow → Murmansk endpoints — NOT first-leg-only - // Moscow → St Petersburg. - expect(railText).toContain("Мурманск"); - expect(railText).not.toContain("Санкт-Петербург"); + expect(railText).toContain("SU 5752"); + expect(railText).toContain("SU 6837"); + // Combined Vladivostok → Mirny endpoints — NOT first-leg-only + // Vladivostok → Krasnoyarsk. + expect(railText).toContain("Мирный"); + expect(railText).not.toContain("Красноярск"); // No unrelated route-mates from the parent search. - expect(railText).not.toMatch(/SU\s*6190/); - expect(railText).not.toMatch(/SU\s*6699/); + expect(railText).not.toMatch(/SU\s*5644/); }); diff --git a/tests/e2e/schedule-details-summary-header.spec.ts b/tests/e2e/schedule-details-summary-header.spec.ts index 685cef92..6d7d0f9b 100644 --- a/tests/e2e/schedule-details-summary-header.spec.ts +++ b/tests/e2e/schedule-details-summary-header.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; // Schedule details page must render Angular's `` // summary block between the day-tabs strip and the per-leg cards: @@ -13,33 +14,35 @@ import { test, expect } from "./fixtures/console-gate"; // summary header" or "first-leg-only badge / raw ISO timeline" again. const URL = - "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; + "/ru-ru/schedule/VVO/SU5752-20260518/KJA/SU6837-20260519/MJZ?request=schedule-route-VVO-MJZ-20260518-20260524"; test("summary header — both badges + last-update + formatted full-route timeline", async ({ page, consoleMessages }) => { + await routeScheduleVvoMjzFixtures(page); await page.goto(URL); const summary = page.locator(".schedule-details__summary"); await expect(summary).toBeVisible({ timeout: 15000 }); - // Both flight-number badges present (SU 6188 + SU 6341). + // Both flight-number badges present (SU 5752 + SU 6837). const badges = summary.locator(".details-header-badge"); await expect(badges).toHaveCount(2); - await expect(summary).toContainText("SU 6188"); - await expect(summary).toContainText("SU 6341"); + await expect(summary).toContainText("SU 5752"); + await expect(summary).toContainText("SU 6837"); // Last-update line is rendered (right-side cluster). await expect(summary).toContainText(/Последнее обновление:\s*\d{2}:\d{2}/); // Full-route timeline is present and shows formatted clock times - // (00:30 / 02:00 / 06:30 / 08:20) — NOT raw ISO timestamps with + // (16:30 / 18:35 / 08:40 / 13:00) — NOT raw ISO timestamps with // T-separators or '+03:00' offsets, which was a regression from // using `.local` instead of `.localTime`. const timeline = summary.locator(".full-route-timeline"); await expect(timeline).toBeVisible(); const timelineText = (await timeline.innerText()).replace(/\s+/g, " "); - expect(timelineText).toMatch(/\b00:30\b/); - expect(timelineText).toMatch(/\b02:00\b/); - expect(timelineText).toMatch(/\b08:20\b/); - expect(timelineText).not.toMatch(/2026-04-\d{2}T/); - expect(timelineText).not.toContain("+03:00"); + expect(timelineText).toMatch(/\b16:30\b/); + expect(timelineText).toMatch(/\b18:35\b/); + expect(timelineText).toMatch(/\b08:40\b/); + expect(timelineText).toMatch(/\b13:00\b/); + expect(timelineText).not.toMatch(/2026-05-\d{2}T/); + expect(timelineText).not.toMatch(/\+\d{2}:\d{2}/); }); diff --git a/tests/e2e/schedule-flight-details-button.spec.ts b/tests/e2e/schedule-flight-details-button.spec.ts index 600d5a62..85d64275 100644 --- a/tests/e2e/schedule-flight-details-button.spec.ts +++ b/tests/e2e/schedule-flight-details-button.spec.ts @@ -1,10 +1,17 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; + +const ROUTE_URL = "/ru-ru/schedule/route/VVO-MJZ-20260518-20260524"; test.describe("Schedule flight details button", () => { + test.beforeEach(async ({ page }) => { + await routeScheduleVvoMjzFixtures(page); + }); + test("flight details button is visible in expanded flight body", async ({ page, }) => { - await page.goto("/ru-ru/schedule/route/SVO-LED-20260415"); + await page.goto(ROUTE_URL); const cards = page.locator(".flight-card--clickable"); await expect(cards.first()).toBeVisible({ timeout: 30000 }); @@ -19,7 +26,7 @@ test.describe("Schedule flight details button", () => { }); test("flight details button has correct label (Russian)", async ({ page }) => { - await page.goto("/ru-ru/schedule/route/SVO-LED-20260415"); + await page.goto(ROUTE_URL); const cards = page.locator(".flight-card--clickable"); await expect(cards.first()).toBeVisible({ timeout: 30000 }); @@ -35,7 +42,7 @@ test.describe("Schedule flight details button", () => { test("flight details button navigates to flight details page", async ({ page, }) => { - await page.goto("/ru-ru/schedule/route/SVO-LED-20260415"); + await page.goto(ROUTE_URL); const cards = page.locator(".flight-card--clickable"); await expect(cards.first()).toBeVisible({ timeout: 30000 }); @@ -51,7 +58,7 @@ test.describe("Schedule flight details button", () => { test("flight details button works for connecting flights", async ({ page, }) => { - await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503"); + await page.goto(ROUTE_URL); const cards = page.locator(".flight-card--clickable"); await expect(cards.first()).toBeVisible({ timeout: 30000 }); @@ -71,7 +78,7 @@ test.describe("Schedule flight details button", () => { test("flight details button preserves search context in URL", async ({ page, }) => { - await page.goto("/ru-ru/schedule/route/SVO-LED-20260415"); + await page.goto(ROUTE_URL); const cards = page.locator(".flight-card--clickable"); await expect(cards.first()).toBeVisible({ timeout: 30000 }); @@ -83,6 +90,6 @@ test.describe("Schedule flight details button", () => { const url = page.url(); expect(url).toContain("?request="); - expect(url).toContain("schedule-route-SVO-LED-20260415"); + expect(url).toContain("schedule-route-VVO-MJZ-20260518-20260524"); }); }); diff --git a/tests/e2e/schedule-route-buy-button.spec.ts b/tests/e2e/schedule-route-buy-button.spec.ts index 29d4571d..83880450 100644 --- a/tests/e2e/schedule-route-buy-button.spec.ts +++ b/tests/e2e/schedule-route-buy-button.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; // Schedule search-results page must mirror Angular's // `` strip in each expanded flight body — @@ -11,11 +12,12 @@ import { test, expect } from "./fixtures/console-gate"; test("schedule route page surfaces the buy ticket button inside an expanded flight body", async ({ page, }) => { + await routeScheduleVvoMjzFixtures(page); // Pick a calendar week well clear of the 2h pre-departure cutoff so every // flight in the list is inside the buy window. Earlier-this-week URLs hit // a "today's first flight is < 2h out" edge case and the buy button hides // on that single row, even though the rest of the list shows it. - await page.goto("/ru-ru/schedule/route/MOW-MMK-20260505-20260511"); + await page.goto("/ru-ru/schedule/route/VVO-MJZ-20260518-20260524"); // Wait for the list to render. const cards = page.locator(".flight-card--clickable"); diff --git a/tests/e2e/schedule.spec.ts b/tests/e2e/schedule.spec.ts index d52f0e37..2876a5e9 100644 --- a/tests/e2e/schedule.spec.ts +++ b/tests/e2e/schedule.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { addDays, formatYmd } from "./helpers/dates"; test.describe("Schedule", () => { test("/ru/schedule renders the start page", async ({ page, consoleMessages }) => { @@ -16,14 +17,16 @@ test.describe("Schedule", () => { ).toBeVisible(); }); - test("/ru/schedule/route/SVO-LED-20260415 renders the search page", async ({ + test("/ru/schedule/route/SVO-LED-{today..+6} renders the search page", async ({ page, consoleMessages, }) => { - await page.goto("/ru/schedule/route/SVO-LED-20260415"); + const from = formatYmd(new Date()); + const to = formatYmd(addDays(new Date(), 6)); + await page.goto(`/ru/schedule/route/SVO-LED-${from}-${to}`); await page.waitForLoadState("domcontentloaded"); - expect(page.url()).toContain("/ru/schedule/route/SVO-LED-20260415"); + expect(page.url()).toContain(`/schedule/route/SVO-LED-${from}-${to}`); // Page should render something (search results, loading, or error) await expect(page.locator("body")).not.toBeEmpty(); diff --git a/tests/e2e/search-history-label.spec.ts b/tests/e2e/search-history-label.spec.ts index 9aa29477..1e637cfe 100644 --- a/tests/e2e/search-history-label.spec.ts +++ b/tests/e2e/search-history-label.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures/console-gate"; +import { addDays, formatYmd } from "./helpers/dates"; // TIRREDESIGN-5: the search-history sidebar header must read // "Ранее искали" (not "Вы искали"). The block exists on Schedule @@ -7,26 +8,27 @@ import { test, expect } from "./fixtures/console-gate"; // the test is deterministic. // Matches `searchHistoryItemSchema` in useSearchHistory.ts. -const SEED = [ - { - type: "schedule-route" as const, - url: "/ru-ru/schedule/route/MOW-LED-20260427-20260503", - label: "Москва — Санкт-Петербург", - params: { - departure: "MOW", - arrival: "LED", - dateFrom: "20260427", - dateTo: "20260503", - }, - }, -]; - async function seedHistory(page: import("@playwright/test").Page) { + const dateFrom = formatYmd(new Date()); + const dateTo = formatYmd(addDays(new Date(), 6)); + const seed = [ + { + type: "schedule-route" as const, + url: `/ru-ru/schedule/route/MOW-LED-${dateFrom}-${dateTo}`, + label: "Москва — Санкт-Петербург", + params: { + departure: "MOW", + arrival: "LED", + dateFrom, + dateTo, + }, + }, + ]; // Storage uses sessionStorage with the `afl_` namespace prefix and is // keyed `history_${lang}` — see src/shared/hooks/useSearchHistory.ts. await page.addInitScript((items) => { window.sessionStorage.setItem("afl_history_ru", JSON.stringify(items)); - }, SEED); + }, seed); } test.describe("Search-history label is 'Ранее искали' (TIRREDESIGN-5)", () => { diff --git a/tests/fixtures/api/schedule-details-vvo-mjz.json b/tests/fixtures/api/schedule-details-vvo-mjz.json new file mode 100644 index 00000000..58282836 --- /dev/null +++ b/tests/fixtures/api/schedule-details-vvo-mjz.json @@ -0,0 +1 @@ +{"data":{"routes":[{"routeType":"Direct","operatingBy":{"scheduled":"FV","operators":[]},"status":"Scheduled","id":"8120fbaa-c015-4937-9e21-575921dc7cff","flightId":{"dateLT":"2026-05-18","carrier":"SU","flightNumber":"5752","suffix":"","date":"2026-05-18"},"flyingTime":"05:05:00","leg":{"departure":{"scheduled":{"city":"Владивосток","airport":"Владивосток","countryCode":"RU","cityCode":"VVO","airportCode":"VVO"},"latest":{"city":"Владивосток","airport":"Владивосток","countryCode":"RU","cityCode":"VVO","airportCode":"VVO"},"times":{"scheduledDeparture":{"utc":"2026-05-18T06:30:00Z","local":"2026-05-18T16:30:00+10:00","dayChange":{"value":0,"title":""},"localTime":"16:30","tzOffset":600.0},"estimatedBlockOff":{"utc":"2026-05-18T06:30:00Z","local":"2026-05-18T16:30:00+10:00","dayChange":{"value":0,"title":""},"localTime":"16:30","tzOffset":600.0}}},"arrival":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"latest":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledArrival":{"utc":"2026-05-18T11:35:00Z","local":"2026-05-18T18:35:00+07:00","dayChange":{"value":0,"title":""},"localTime":"18:35","tzOffset":420.0},"estimatedBlockOn":{"utc":"2026-05-18T11:35:00Z","local":"2026-05-18T18:35:00+07:00","dayChange":{"value":0,"title":""},"localTime":"18:35","tzOffset":420.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2026-05-11T13:09:19Z","status":"Scheduled","operatingBy":{"scheduled":"FV","operators":[]},"transition":{},"daysOfWeek":{"current":"16","flight":"1467"},"flyingTime":"05:05:00","equipment":{"meal":[{"type":"Business"},{"type":"Economy"},{"type":"Comfort"},{"type":"Special"}],"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"319","title":"Airbus A319"},"actualType":{"title":""},"actual":{"type":"319","title":"Airbus A319","registration":"73203","name":"Ижевск"},"configuration":{"seats":[{"type":"Business","count":8},{"type":"Economy","count":120}]},"previousFlight":{"localDate":"2026-05-18","pId":"8120fbaa-c015-4937-9e21-575921dc7cff","carrier":"SU","flightNumber":"5752","suffix":"","date":"2026-05-18"}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":true}],"bookingClasses":{"codes":"J,O,C,I,Z,Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}},{"routeType":"Direct","operatingBy":{"scheduled":"FV","operators":[]},"status":"Scheduled","id":"19f52e4a-4fb4-4eea-b23c-dd3eec2ad723","flightId":{"dateLT":"2026-05-19","carrier":"SU","flightNumber":"6837","suffix":"","date":"2026-05-19"},"flyingTime":"02:20:00","leg":{"departure":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"latest":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledDeparture":{"utc":"2026-05-19T01:40:00Z","local":"2026-05-19T08:40:00+07:00","dayChange":{"value":1,"title":"+1"},"localTime":"08:40","tzOffset":420.0},"estimatedBlockOff":{"utc":"2026-05-19T01:40:00Z","local":"2026-05-19T08:40:00+07:00","dayChange":{"value":1,"title":"+1"},"localTime":"08:40","tzOffset":420.0}}},"arrival":{"scheduled":{"city":"Мирный","airport":"Аэропорт Мирный","countryCode":"RU","cityCode":"MJZ","airportCode":"MJZ"},"latest":{"city":"Мирный","airport":"Аэропорт Мирный","countryCode":"RU","cityCode":"MJZ","airportCode":"MJZ"},"times":{"scheduledArrival":{"utc":"2026-05-19T04:00:00Z","local":"2026-05-19T13:00:00+09:00","dayChange":{"value":1,"title":"+1"},"localTime":"13:00","tzOffset":540.0},"estimatedBlockOn":{"utc":"2026-05-19T04:00:00Z","local":"2026-05-19T13:00:00+09:00","dayChange":{"value":1,"title":"+1"},"localTime":"13:00","tzOffset":540.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2026-05-12T07:24:52Z","status":"Scheduled","operatingBy":{"scheduled":"FV","operators":[]},"transition":{},"daysOfWeek":{"current":"26","flight":"26"},"flyingTime":"02:20:00","equipment":{"meal":[{"type":"Economy"},{"type":"Business"},{"type":"Comfort"}],"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"SU9","title":"Sukhoi SuperJet 100"},"actualType":{"title":""},"actual":{"type":"SU9","title":"Sukhoi SuperJet 100","registration":"89178","name":"Элиста"},"configuration":{"seats":[{"type":"Economy","count":100}]},"previousFlight":{"localDate":"2026-05-19","pId":"19f52e4a-4fb4-4eea-b23c-dd3eec2ad723","carrier":"SU","flightNumber":"6837","suffix":"","date":"2026-05-19"}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":false}],"bookingClasses":{"codes":"Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}}],"partners":[],"daysOfFlight":["20260516","20260523","20260530","20260606","20260613","20260620","20260627","20260704","20260711","20260718","20260725","20260801","20260808","20260815","20260822","20260829","20260905","20260912","20260919","20260926","20261003","20261010","20261017","20261024"]}} \ No newline at end of file diff --git a/tests/fixtures/api/schedule-search-vvo-mjz.json b/tests/fixtures/api/schedule-search-vvo-mjz.json new file mode 100644 index 00000000..c3c54333 --- /dev/null +++ b/tests/fixtures/api/schedule-search-vvo-mjz.json @@ -0,0 +1 @@ +[{"routeType":"Connecting","status":"Scheduled","flyingTime":"07:25:00","flights":[{"routeType":"Direct","operatingBy":{"scheduled":"FV","operators":[]},"status":"Scheduled","id":"8120fbaa-c015-4937-9e21-575921dc7cff","flightId":{"carrier":"SU","flightNumber":"5752","suffix":"","date":"2026-05-18"},"flyingTime":"05:05:00","leg":{"departure":{"scheduled":{"city":"Владивосток","airport":"Владивосток","countryCode":"RU","cityCode":"VVO","airportCode":"VVO"},"times":{"scheduledDeparture":{"utc":"2026-05-18T06:30:00Z","local":"2026-05-18T16:30:00+10:00","dayChange":{"value":0,"title":""},"localTime":"16:30","tzOffset":600.0}}},"arrival":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledArrival":{"utc":"2026-05-18T11:35:00Z","local":"2026-05-18T18:35:00+07:00","dayChange":{"value":0,"title":""},"localTime":"18:35","tzOffset":420.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2026-05-11T13:09:19Z","status":"Scheduled","operatingBy":{"scheduled":"FV","operators":[]},"daysOfWeek":{"current":"16","flight":"1467"},"flyingTime":"05:05:00","equipment":{"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"319","title":"Airbus A319"},"actualType":{"title":""}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":true}],"bookingClasses":{"codes":"J,O,C,I,Z,Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}},{"routeType":"Direct","operatingBy":{"scheduled":"FV","operators":[]},"status":"Scheduled","id":"19f52e4a-4fb4-4eea-b23c-dd3eec2ad723","flightId":{"carrier":"SU","flightNumber":"6837","suffix":"","date":"2026-05-19"},"flyingTime":"02:20:00","leg":{"departure":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledDeparture":{"utc":"2026-05-19T01:40:00Z","local":"2026-05-19T08:40:00+07:00","dayChange":{"value":1,"title":"+1"},"localTime":"08:40","tzOffset":420.0}}},"arrival":{"scheduled":{"city":"Мирный","airport":"Аэропорт Мирный","countryCode":"RU","cityCode":"MJZ","airportCode":"MJZ"},"times":{"scheduledArrival":{"utc":"2026-05-19T04:00:00Z","local":"2026-05-19T13:00:00+09:00","dayChange":{"value":1,"title":"+1"},"localTime":"13:00","tzOffset":540.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2026-05-12T07:24:52Z","status":"Scheduled","operatingBy":{"scheduled":"FV","operators":[]},"daysOfWeek":{"current":"26","flight":"26"},"flyingTime":"02:20:00","equipment":{"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"SU9","title":"Sukhoi SuperJet 100"},"actualType":{"title":""}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":false}],"bookingClasses":{"codes":"Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}}]},{"routeType":"Connecting","status":"Scheduled","flyingTime":"07:30:00","flights":[{"routeType":"Direct","operatingBy":{"scheduled":"HZ","operators":[]},"status":"Scheduled","id":"67ce4f82-0bda-41fb-b816-09e62c758a62","flightId":{"carrier":"SU","flightNumber":"5644","suffix":"","date":"2026-05-22"},"flyingTime":"05:10:00","leg":{"departure":{"scheduled":{"city":"Владивосток","airport":"Владивосток","countryCode":"RU","cityCode":"VVO","airportCode":"VVO"},"times":{"scheduledDeparture":{"utc":"2026-05-22T04:25:00Z","local":"2026-05-22T14:25:00+10:00","dayChange":{"value":0,"title":""},"localTime":"14:25","tzOffset":600.0}}},"arrival":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledArrival":{"utc":"2026-05-22T09:35:00Z","local":"2026-05-22T16:35:00+07:00","dayChange":{"value":0,"title":""},"localTime":"16:35","tzOffset":420.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2026-03-30T08:15:27Z","status":"Scheduled","operatingBy":{"scheduled":"HZ","operators":[]},"daysOfWeek":{"current":"56","flight":"23567"},"flyingTime":"05:10:00","equipment":{"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"319","title":"Airbus A319"},"actualType":{"title":""}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":true}],"bookingClasses":{"codes":"J,O,C,I,Z,Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}},{"routeType":"Direct","operatingBy":{"scheduled":"FV","operators":[]},"status":"Scheduled","id":"9da26f43-c889-4101-be3b-58cde764c6f3","flightId":{"carrier":"SU","flightNumber":"6837","suffix":"","date":"2026-05-23"},"flyingTime":"02:20:00","leg":{"departure":{"scheduled":{"city":"Красноярск","airport":"Емельяново","countryCode":"RU","cityCode":"KJA","airportCode":"KJA"},"terminal":"1","times":{"scheduledDeparture":{"utc":"2026-05-23T01:40:00Z","local":"2026-05-23T08:40:00+07:00","dayChange":{"value":1,"title":"+1"},"localTime":"08:40","tzOffset":420.0}}},"arrival":{"scheduled":{"city":"Мирный","airport":"Аэропорт Мирный","countryCode":"RU","cityCode":"MJZ","airportCode":"MJZ"},"times":{"scheduledArrival":{"utc":"2026-05-23T04:00:00Z","local":"2026-05-23T13:00:00+09:00","dayChange":{"value":1,"title":"+1"},"localTime":"13:00","tzOffset":540.0}}},"flags":{"checkinAvailable":false,"purchaseAvailable":true,"statusAvailable":false,"routeChanged":false,"returnToAirport":false},"updated":"2025-12-30T15:58:55Z","status":"Scheduled","operatingBy":{"scheduled":"FV","operators":[]},"daysOfWeek":{"current":"26","flight":"26"},"flyingTime":"02:20:00","equipment":{"serviceType":{"code":"J"},"aircraft":{"scheduled":{"type":"SU9","title":"Sukhoi SuperJet 100"},"actualType":{"title":""}},"serviceClasses":[{"code":"Economy","mealTypes":"M","canBookPremium":true},{"code":"Comfort","mealTypes":"M","canBookPremium":false},{"code":"Business","mealTypes":"M","canBookPremium":false}],"bookingClasses":{"codes":"Y,X,D,S,B,M,U,P,K,H,L,Q,T,E,N,R,G,V"}},"trafficRestrictions":[""]}}]}] \ No newline at end of file