Files
ridebus-pwa/e2e/app.spec.ts
2026-10-07 01:48:32 +03:00

207 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test';
test('stop search, route filter and city-scoped favorites survive reload', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1');
await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1');
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
await expect(page.locator('.stopItem')).toHaveCount(1);
await page.locator('.stopItem').first().click();
await expect(page.locator('.departureRow')).toHaveCount(8);
const directions = page.getByRole('group', { name: 'Направления', exact: true });
await expect(directions.getByRole('button')).toHaveCount(2);
await directions.getByRole('button', { name: 'В сторону центра', exact: true }).click();
await expect(page).toHaveURL(/stop=102/);
await expect(directions.getByRole('button', { name: 'В сторону центра', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.departureRow')).toHaveCount(8);
await directions.getByRole('button', { name: 'В сторону автовокзала', exact: true }).click();
await expect(page).toHaveURL(/stop=101/);
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
await expect(page.locator('.departureRow')).toHaveCount(2);
await page.getByRole('button', { name: 'Все маршруты', exact: true }).click();
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
if (info.project.name === 'mobile') {
const mobileFavorite = page.getByRole('navigation', { name: 'Навигация на телефоне' }).getByRole('link', { name: /Избранное/ });
await expect(mobileFavorite.locator('.mobileBadge')).toHaveText('1');
await expect(mobileFavorite).toHaveText('1Избранное');
const badge = await mobileFavorite.locator('.mobileBadge').boundingBox();
const icon = await mobileFavorite.locator('.navIcon').boundingBox();
expect(badge!.y).toBeLessThan(icon!.y);
expect(badge!.x + badge!.width).toBeGreaterThan(icon!.x + icon!.width);
}
await page.getByRole('button', { name: 'Закрыть уведомление', exact: true }).click();
await page.screenshot({ path: `test-results/ridebus-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация', exact: true }).getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.favoriteDepartures li')).toHaveCount(3);
await page.reload();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click();
await expect(page.locator('#board-title')).toHaveText('Площадь Свободы');
await expect(page.getByRole('group', { name: 'Направления' }).getByRole('button', { name: 'В сторону автовокзала', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('#board-title')).toHaveText('Ваша остановка');
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
await page.getByRole('button', { name: /Площадь Строителей/ }).click();
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
await expect(page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true })).toBeVisible();
expect(errors).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test('every grouped stop is visible without an eighty-stop cap', async ({ page }) => {
await page.route('**/api/data?**', route => {
const resource = new URL(route.request().url()).searchParams.get('resource');
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }]
: Array.from({ length: 115 }, (_, i) => ({ id: i + 1, name: `Остановка ${String(i + 1).padStart(3, '0')}`, direction: '', routesCount: 1 }));
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
await page.goto('/');
await expect(page.locator('.stopItem')).toHaveCount(115);
await expect(page.locator('.stopsFooter')).toContainText('115 остановок');
await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
await expect(page.locator('.stopItem').last()).toBeVisible();
await expect(page.locator('main')).not.toContainText('Показаны первые');
});
test('text is readable on narrow screens and contains no technical API wording', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
const tiny = await page.locator('main, footer, .mobileNav').evaluateAll(roots => roots.flatMap(root => [...root.querySelectorAll<HTMLElement>('p, label, strong, button, small, span')]).filter(element => {
const visible = element.getClientRects().length > 0;
return visible && element.textContent?.trim() && Number.parseFloat(getComputedStyle(element).fontSize) < 13 && !element.classList.contains('mobileBadge');
}).map(element => ({ text: element.textContent, size: getComputedStyle(element).fontSize })));
expect(tiny).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('main')).not.toContainText('API');
await expect(page.locator('body')).not.toContainText('МЕНЬШЕ ОЖИДАНИЯ');
await expect(page.getByRole('contentinfo')).toContainText('Расписание в вашем кармане');
});
test('installed service worker serves shell, favorites and saved departures offline', async ({ page, context }) => {
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
await context.setOffline(true);
await page.reload();
await expect(page.locator('.offlineBanner')).toBeVisible();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
await context.setOffline(false);
});
test('API failure is explicit and does not silently substitute demo data', async ({ page }) => {
await page.route('**/api/data?**', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'api_not_configured' }) }));
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Подключаем расписание' })).toBeVisible();
await expect(page.locator('.demoBanner')).toHaveCount(0);
await expect(page.locator('.departureRow')).toHaveCount(0);
await expect(page.getByRole('button', { name: /Посмотреть пример/ })).toHaveCount(0);
await expect(page.getByLabel('Остановки', { exact: true })).not.toContainText('на примере');
await page.goto('/?demo=1');
await expect(page.locator('.demoBanner')).toBeVisible();
});
test('a stale timetable loses expired departures across the date boundary', async ({ page, context }) => {
await page.clock.install({ time: new Date('2026-10-06T20:59:00Z') });
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await context.setOffline(true);
await page.clock.fastForward('02:00:00');
await expect(page.locator('.departureRow')).toHaveCount(0);
await expect(page.getByText('В сохранённых данных не осталось', { exact: false })).toBeVisible();
});
test('full timetable keeps past trips, switches date and direction, and restores its URL', async ({ page }, info) => {
await page.clock.install({ time: new Date('2026-10-07T09:00:00Z') });
await page.goto('/?demo=1&city=1&stop=101');
await page.getByRole('button', { name: 'На дату', exact: true }).click();
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
await expect(page.locator('.pastDeparture')).not.toHaveCount(0);
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-08');
await expect(page.locator('.timetableSummary')).toContainText('8 октября');
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
await expect(page.locator('.timetableSummary')).toContainText('8 рейсов');
await expect(page.locator('.hourDeparture')).toHaveCount(8);
await expect(page.locator('.hourRoute')).toHaveCount(0);
await page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В сторону центра', exact: true }).click();
await expect(page).toHaveURL(/stop=102/);
await expect(page).toHaveURL(/date=2026-10-08/);
await page.reload();
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
await expect(page.getByRole('button', { name: 'На дату', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
await page.screenshot({ path: `test-results/timetable-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('button', { name: 'Ближайшие', exact: true }).click();
await expect(page.locator('.timetableSummary')).toHaveCount(0);
await expect(page.locator('.departureRow')).toHaveCount(8);
});
test('saved full timetable works offline and does not substitute another date', async ({ page, context }) => {
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
await context.setOffline(true);
await page.reload();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-09');
await expect(page.getByRole('heading', { name: 'Нет сохранённого расписания', exact: true })).toBeVisible();
await expect(page.locator('.departureRow')).toHaveCount(0);
});
test('hours and minutes are consistent in departures and favorites, which expire offline', async ({ page, context }, info) => {
const time = Date.parse('2026-10-07T09:00:00Z');
await page.clock.install({ time: new Date(time) });
await page.route('**/api/data?**', route => {
const resource = new URL(route.request().url()).searchParams.get('resource');
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }]
: resource === 'stops' ? [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 1 }]
: resource === 'alerts' ? [] : { routes: [{ id: 1, number: '112С', name: 'В центр' }], partial: false, updatedAt: new Date(time).toISOString(), departures: [{ routeId: 1, routeNumber: '112С', direction: 'В центр', scheduledTime: '14:01', scheduledAt: new Date(time + 121 * 60000).toISOString() }] };
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
await page.goto('/?city=1&stop=101');
await expect(page.locator('.departureTime')).toContainText('через 2 ч 1 мин');
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
await expect(page.locator('.favoriteDepartures')).toContainText('через 2 ч 1 мин');
await page.setViewportSize({ width: 320, height: 780 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/favorites-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await context.setOffline(true);
await page.clock.fastForward('03:00:00');
await expect(page.locator('.favoriteDepartures li')).toHaveCount(0);
await expect(page.locator('.favoriteStatus')).toContainText('данные могут быть устаревшими');
});
test('about page is accessible from the footer and fits a narrow screen', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 780 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1');
await page.getByRole('contentinfo').getByRole('link', { name: 'О проекте', exact: true }).click();
await expect(page).toHaveURL('/about?demo=1');
await expect(page.getByRole('heading', { name: 'Привет, я Захар.', exact: true })).toBeVisible();
await expect(page.locator('.aboutSupport')).toContainText('хостинг, домен');
await expect(page.locator('.supportNote')).toContainText('Ссылку для поддержки добавлю чуть позже');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/about-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('link', { name: 'К расписанию', exact: true }).click();
await expect(page).toHaveURL('/?demo=1');
expect(errors).toEqual([]);
});