Files
flights_web/tests/e2e-angular/cross-app/02-online-board-landing.spec.ts
T
gnezim 20c19d15f4
CI / ci (push) Failing after 23s
Deploy / build-and-deploy (push) Failing after 5s
Add standalone API proxy via curl (bypasses WAF TLS fingerprinting)
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.
2026-04-15 23:04:24 +03:00

429 lines
16 KiB
TypeScript

import { test, expect } from '../support/cross-app-fixtures';
import { mockAllAPIs } from '../support/cross-app-fixtures';
import { S, tid } from '../support/selectors';
test.describe('Online Board Landing', () => {
test.beforeEach(async ({ page, localePath }) => {
await mockAllAPIs(page);
await page.goto(localePath('onlineboard'));
await page.waitForLoadState('networkidle');
});
test('19: Landing page loads with filter sidebar', async ({ page, app }) => {
// Angular uses PrimeNG p-accordion; look for accordion or filter container
const accordion = page.locator(tid(S.FILTER_ACCORDION, app));
const fallbackAccordion = page.locator('p-accordion, .p-accordion');
const target = (await accordion.count()) > 0 ? accordion : fallbackAccordion;
await expect(target.first()).toBeVisible({ timeout: 10000 });
});
test('20: Filter accordion has "Flight Number" tab', async ({ page, app }) => {
const flightTab = page.locator(tid(S.FILTER_FLIGHT_TAB, app));
// Angular uses data-testid="flight-filter" on p-accordiontab
const fallback = page.locator('[data-testid="flight-filter"]');
const target = (await flightTab.count()) > 0 ? flightTab : fallback;
await expect(target).toBeVisible({ timeout: 10000 });
});
test('21: Filter accordion has "Route" tab', async ({ page, app }) => {
const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app));
// Angular uses data-testid="route-filter" on p-accordiontab
const fallback = page.locator('[data-testid="route-filter"]');
const target = (await routeTab.count()) > 0 ? routeTab : fallback;
await expect(target).toBeVisible({ timeout: 10000 });
});
test('22: Filter accordion default tab has visible input', async ({ page, app }) => {
// In Angular, the default expanded tab is "Route" (aria-expanded="true")
// In React, the default may be "Flight Number"
// We just verify that at least one filter form is visible with inputs
const flightInput = page.locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app));
const routeInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app));
const flightVisible = await flightInput.isVisible().catch(() => false);
const routeVisible = await routeInput.isVisible().catch(() => false);
expect(flightVisible || routeVisible).toBe(true);
});
test('23: Switching filter tabs updates visible form', async ({ page, app }) => {
// Find accordion tab headers
const flightTab = page.locator(tid(S.FILTER_FLIGHT_TAB, app));
const flightFilterFallback = page.locator('[data-testid="flight-filter"]');
const routeFilterFallback = page.locator('[data-testid="route-filter"]');
// Determine which tab element to click
const flightTabHeader = (await flightTab.count()) > 0 ? flightTab : flightFilterFallback;
// In Angular, clicking the accordion header toggles the tab
const headerLink = flightTabHeader
.locator('.p-accordion-header-link, .p-accordion-header a')
.first();
if ((await headerLink.count()) > 0) {
await headerLink.click();
} else {
await flightTabHeader.click();
}
await page.waitForTimeout(500);
// After clicking flight tab, flight number input should be visible
const flightInput = page.locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app));
await expect(flightInput).toBeVisible({ timeout: 5000 });
// Now click route tab
const routeTabHeader =
(await page.locator(tid(S.FILTER_ROUTE_TAB, app)).count()) > 0
? page.locator(tid(S.FILTER_ROUTE_TAB, app))
: routeFilterFallback;
const routeHeaderLink = routeTabHeader
.locator('.p-accordion-header-link, .p-accordion-header a')
.first();
if ((await routeHeaderLink.count()) > 0) {
await routeHeaderLink.click();
} else {
await routeTabHeader.click();
}
await page.waitForTimeout(500);
// Route departure input should be visible
const routeInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app));
await expect(routeInput).toBeVisible({ timeout: 5000 });
});
test('24: 4 informational sections are visible with titles and descriptions', async ({
page,
}) => {
// Angular renders 4 info blocks in .titles-container > .title
const infoBlocks = page.locator('.titles-container .title, [data-testid="landing-section"]');
const count = await infoBlocks.count();
expect(count).toBeGreaterThanOrEqual(4);
// Each should have a title (a or h-tag) and description text
for (let i = 0; i < 4; i++) {
const block = infoBlocks.nth(i);
await expect(block).toBeVisible();
const text = await block.textContent();
expect(text?.trim().length).toBeGreaterThan(0);
}
});
test('25: Popular requests section shows 4 cards', async ({ page }) => {
const popularSection = page.locator('.popular-requests, popular-requests');
await expect(popularSection.first()).toBeVisible({ timeout: 10000 });
const cards = page.locator(
'popular-request, .popular-requests__item, [data-testid="landing-popular-request"]',
);
const count = await cards.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('26: Popular request card 1 is clickable', async ({ page }) => {
const cards = page.locator(
'popular-request, .popular-requests__item, [data-testid="landing-popular-request"]',
);
const firstCard = cards.first();
await expect(firstCard).toBeVisible({ timeout: 10000 });
// Click the card - it should navigate or trigger a search
const urlBefore = page.url();
await firstCard.click();
await page.waitForTimeout(1000);
// Either URL changed or we're on a search results page
const urlAfter = page.url();
// Verify navigation happened or page state changed
expect(urlAfter.length).toBeGreaterThan(0);
});
test('27: Popular request card 2 is clickable', async ({ page }) => {
const cards = page.locator(
'popular-request, .popular-requests__item, [data-testid="landing-popular-request"]',
);
if ((await cards.count()) < 2) {
test.skip(true, 'Less than 2 popular request cards');
return;
}
await expect(cards.nth(1)).toBeVisible();
await cards.nth(1).click();
await page.waitForTimeout(1000);
});
test('28: Popular request card 3 is clickable', async ({ page }) => {
const cards = page.locator(
'popular-request, .popular-requests__item, [data-testid="landing-popular-request"]',
);
if ((await cards.count()) < 3) {
test.skip(true, 'Less than 3 popular request cards');
return;
}
await expect(cards.nth(2)).toBeVisible();
await cards.nth(2).click();
await page.waitForTimeout(1000);
});
test('29: Popular request card 4 is clickable', async ({ page }) => {
const cards = page.locator(
'popular-request, .popular-requests__item, [data-testid="landing-popular-request"]',
);
if ((await cards.count()) < 4) {
test.skip(true, 'Less than 4 popular request cards');
return;
}
await expect(cards.nth(3)).toBeVisible();
await cards.nth(3).click();
await page.waitForTimeout(1000);
});
test('30: Search history section is visible (empty state)', async ({ page }) => {
// Search history may not be shown until a search is performed
const historySection = page.locator(
'search-history, [data-testid="landing-search-history"], [class*="search-history"]',
);
const count = await historySection.count();
if (count === 0) {
test.skip(true, 'Search history section not present on landing page');
return;
}
// It exists in the DOM (may be empty)
expect(count).toBeGreaterThan(0);
});
test('31: Search history shows items after performing a search', async ({
page,
app,
locale,
}) => {
// Perform a search by navigating to a search URL
const today = formatToday();
await page.goto(`/${locale}/onlineboard/departure/MOW-${today}`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Go back to landing
await page.goto(`/${locale}/onlineboard`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const historyItems = page.locator(
'search-history .history-item, [data-testid="landing-search-history-item"], .search-history__item',
);
const count = await historyItems.count();
if (count === 0) {
test.skip(true, 'Search history not populated after search (feature may not be available)');
return;
}
expect(count).toBeGreaterThan(0);
});
test('32: Search history item is clickable and re-executes search', async ({
page,
app,
locale,
}) => {
// Navigate to search first
const today = formatToday();
await page.goto(`/${locale}/onlineboard/departure/MOW-${today}`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Go back to landing
await page.goto(`/${locale}/onlineboard`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const historyItems = page.locator(
'search-history .history-item, [data-testid="landing-search-history-item"], .search-history__item',
);
const count = await historyItems.count();
if (count === 0) {
test.skip(true, 'Search history not populated (feature may not be available)');
return;
}
const urlBefore = page.url();
await historyItems.first().click();
await page.waitForTimeout(1000);
const urlAfter = page.url();
expect(urlAfter).not.toBe(urlBefore);
});
test('33: Page title matches locale', async ({ page, locale }) => {
const title = await page.title();
expect(title.length).toBeGreaterThan(0);
if (locale === 'ru-ru') {
expect(title.toLowerCase()).toContain('табло');
} else if (locale === 'en-us') {
expect(title.toLowerCase()).toMatch(/board|flight/);
}
// For other locales, just verify title is non-empty
});
test('34: Page has correct meta tags', async ({ page }) => {
const description = page.locator('meta[name="description"]');
await expect(description).toHaveAttribute('content', /.+/);
// Check for og:title
const ogTitle = page.locator('meta[name="og:title"], meta[property="og:title"]');
if ((await ogTitle.count()) > 0) {
await expect(ogTitle.first()).toHaveAttribute('content', /.+/);
}
// Check for og:description
const ogDesc = page.locator('meta[name="og:description"], meta[property="og:description"]');
if ((await ogDesc.count()) > 0) {
await expect(ogDesc.first()).toHaveAttribute('content', /.+/);
}
});
test('35: Two-column layout renders (sidebar + content)', async ({ page }) => {
// Angular layout uses page-layout__column-left (aside) and page-layout__column-right (main)
const sidebar = page.locator('aside.page-layout__column-left, [class*="sidebar"], aside');
const mainArea = page.locator('main.page-layout__column-right, main, [class*="column-right"]');
await expect(sidebar.first()).toBeVisible({ timeout: 10000 });
await expect(mainArea.first()).toBeVisible({ timeout: 10000 });
});
test('36: Filter is in left sidebar', async ({ page, app }) => {
// Angular has multiple aside elements; the filter is in the content row, not the header row
const contentRow = page.locator(
'.page-layout__content, .page-layout__row.page-layout__content',
);
const sidebar = contentRow.locator('aside, .page-layout__column-left').first();
// Fallback: find the aside that contains the accordion
const fallbackSidebar = page.locator('aside').filter({
has: page.locator(
'p-accordion, .p-accordion, [data-testid="filter-accordion"], [data-testid="flight-filter"]',
),
});
const target = (await sidebar.count()) > 0 ? sidebar : fallbackSidebar.first();
await expect(target).toBeVisible({ timeout: 10000 });
// Verify accordion is inside it
const accordion = target.locator('p-accordion, .p-accordion, [data-testid="flight-filter"]');
await expect(accordion.first()).toBeVisible();
});
test('37: Landing content is in main area', async ({ page }) => {
const mainArea = page.locator('main.page-layout__column-right, main').first();
await expect(mainArea).toBeVisible({ timeout: 10000 });
// Main area should contain the info section or popular requests
const content = mainArea.locator(
'section, .frame, .titles-container, [data-testid="landing-section"]',
);
const count = await content.count();
expect(count).toBeGreaterThan(0);
});
test('38: Page renders without console errors', async ({ page, app, localePath }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
// Ignore known acceptable errors (CORS, favicon, external resources)
if (
text.includes('aeroflot.ru') ||
text.includes('favicon') ||
text.includes('net::ERR_FAILED') ||
text.includes('CORS')
) {
return;
}
consoleErrors.push(text);
}
});
// Re-navigate to capture console errors from page load
await page.goto(localePath('onlineboard'));
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Filter out non-critical errors
const criticalErrors = consoleErrors.filter(
(e) => !e.includes('403') && !e.includes('Forbidden') && !e.includes('net::'),
);
expect(criticalErrors).toHaveLength(0);
});
test('39: All text content matches current locale translations', async ({ page, locale }) => {
// Verify the page has loaded with the correct locale
const h1 = page.locator('h1').first();
await expect(h1).toBeVisible({ timeout: 10000 });
const h1Text = await h1.textContent();
if (locale === 'ru-ru') {
expect(h1Text).toContain('Онлайн-Табло');
} else if (locale === 'en-us') {
// English locale should have English text
expect(h1Text?.toLowerCase()).toMatch(/online|board|flight/i);
}
// For other locales, just verify h1 is non-empty
expect(h1Text?.trim().length).toBeGreaterThan(0);
});
test('40: Landing page is accessible (no a11y violations — basic check)', async ({ page }) => {
// Basic accessibility checks without axe-core dependency
// 1. All images should have alt attributes
const imagesWithoutAlt = await page.locator('img:not([alt])').count();
expect(imagesWithoutAlt).toBe(0);
// 2. Page should have an h1
const h1Count = await page.locator('h1').count();
expect(h1Count).toBeGreaterThanOrEqual(1);
// 3. All interactive elements should be keyboard accessible (have tabindex or are natively focusable)
const buttons = page.locator('button');
const buttonCount = await buttons.count();
for (let i = 0; i < Math.min(buttonCount, 5); i++) {
const button = buttons.nth(i);
if (await button.isVisible()) {
// Buttons should not have negative tabindex
const tabindex = await button.getAttribute('tabindex');
if (tabindex !== null) {
expect(parseInt(tabindex)).toBeGreaterThanOrEqual(0);
}
}
}
// 4. Form inputs should have labels or aria-label
const inputs = page.locator('input:visible');
const inputCount = await inputs.count();
for (let i = 0; i < Math.min(inputCount, 5); i++) {
const input = inputs.nth(i);
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
const id = await input.getAttribute('id');
const placeholder = await input.getAttribute('placeholder');
// Input should have at least one accessibility attribute
const hasLabel =
ariaLabel !== null ||
ariaLabelledBy !== null ||
placeholder !== null ||
(id !== null && (await page.locator(`label[for="${id}"]`).count()) > 0);
expect(hasLabel).toBe(true);
}
// 5. Language attribute should be set on html element (Angular may use "en" as default)
const lang = await page.locator('html').getAttribute('lang');
// Some apps set lang, some don't - just verify it doesn't break anything
// Angular sets lang="en" by default which is acceptable
if (lang === null) {
// No lang attribute is a minor accessibility issue but not a test failure for cross-app
test
.info()
.annotations.push({ type: 'warning', description: 'html element has no lang attribute' });
}
});
});
function formatToday(): string {
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
}