201 lines
14 KiB
TypeScript
201 lines
14 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
async function seedTrips(page: Page, count: number) {
|
||
await page.evaluate(async count => {
|
||
const request = indexedDB.open('ridebus-pwa');
|
||
const database = await new Promise<IDBDatabase>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||
const transaction = database.transaction('quickTrips', 'readwrite');
|
||
const store = transaction.objectStore('quickTrips');
|
||
store.clear();
|
||
for (let index = 0; index < count; index++) {
|
||
const otherCity = index === 2;
|
||
store.put({ id: `test-trip-${index}`, name: ['Домой', 'На работу', 'К родителям', 'В гости'][index], mode: 'demo', cityId: otherCity ? 2 : 1, cityName: otherCity ? 'Новополоцк' : 'Полоцк',
|
||
stop: { id: otherCity ? 201 : 101, name: otherCity ? 'Площадь Строителей' : 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 },
|
||
route: { id: index + 1, number: ['4', '10', '12', '24'][index], name: ['Автовокзал → Больница', 'Вокзал → Аэропорт', 'Боровуха → Автовокзал', 'Центр → Университет'][index] }, createdAt: index + 1 });
|
||
}
|
||
await new Promise<void>((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); });
|
||
database.close();
|
||
}, count);
|
||
// Native IndexedDB fixture writes are read when the application reloads.
|
||
await page.reload();
|
||
await expect(page.locator('.quickTripCard')).toHaveCount(count);
|
||
}
|
||
|
||
test('journeys use a mobile carousel and switch desktop layout at three cards', async ({ page }, info) => {
|
||
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await seedTrips(page, 2);
|
||
if (info.project.name === 'mobile') await expect(page.locator('.tripsCarouselTrack')).toHaveCount(1);
|
||
else await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
|
||
await seedTrips(page, 3);
|
||
const track = page.getByRole('region', { name: 'Сохранённые поездки', exact: true });
|
||
await expect(track).toBeVisible();
|
||
await expect(track).toHaveAttribute('data-fade-start', 'false');
|
||
await expect(track).toHaveAttribute('data-fade-end', 'true');
|
||
await expect(track).toHaveCSS('mask-image', /linear-gradient/);
|
||
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-fade-${info.project.name}.png`, animations: 'disabled' });
|
||
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeDisabled();
|
||
await page.getByRole('button', { name: 'Следующая поездка' }).click();
|
||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
|
||
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeEnabled();
|
||
await track.focus(); await track.press('Home');
|
||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeLessThan(3);
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
await expect(track).toHaveCSS('scroll-snap-type', 'x mandatory');
|
||
await track.focus(); await track.press('End');
|
||
await expect(page.locator('.carouselControls > span')).toHaveText('3 из 3');
|
||
await expect(page.getByRole('button', { name: 'Следующая поездка' })).toBeDisabled();
|
||
await expect(track).toHaveAttribute('data-fade-start', 'true');
|
||
await expect(track).toHaveAttribute('data-fade-end', 'false');
|
||
await expect.poll(() => track.evaluate(element => element.clientHeight - (element.children[2] as HTMLElement).offsetHeight)).toBeLessThan(30);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-${info.project.name}.png`, animations: 'disabled' });
|
||
await page.getByRole('button', { name: 'Удалить поездку К родителям', exact: true }).click();
|
||
await expect(page.locator('.quickTripCard')).toHaveCount(2);
|
||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||
await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test('touch swipe moves the carousel without moving the whole page sideways', async ({ page, context }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await seedTrips(page, 3);
|
||
const track = page.locator('.tripsCarouselTrack');
|
||
await track.scrollIntoViewIfNeeded();
|
||
const bounds = await track.boundingBox();
|
||
const session = await context.newCDPSession(page);
|
||
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||
const start = bounds!.x + bounds!.width - 25;
|
||
const y = Math.min(600, bounds!.y + 110);
|
||
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: start, y }] });
|
||
for (let step = 1; step <= 8; step++) await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start - step * 28, y }] });
|
||
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await session.detach();
|
||
});
|
||
|
||
test('opening a saved journey scrolls to the correct board across cities and from favorites', async ({ page }) => {
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await seedTrips(page, 3);
|
||
await page.addInitScript(() => {
|
||
const original = HTMLElement.prototype.scrollIntoView;
|
||
(window as any).boardScrolls = [];
|
||
HTMLElement.prototype.scrollIntoView = function(options) {
|
||
if (this.id === 'departures') (window as any).boardScrolls.push(options);
|
||
original.call(this, options);
|
||
};
|
||
});
|
||
await page.goto('/favorites?demo=1');
|
||
const card = page.locator('.quickTripCard').filter({ has: page.getByRole('heading', { name: 'К родителям', exact: true }) });
|
||
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
|
||
await expect(page).toHaveURL(/city=2.*stop=201.*route=3/);
|
||
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
|
||
await expect(page.locator('#departures')).toBeFocused();
|
||
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('smooth');
|
||
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(350);
|
||
await expect(page.getByRole('button', { name: 'Маршрут 12', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
|
||
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('auto');
|
||
await expect(page.locator('#departures')).toBeFocused();
|
||
});
|
||
|
||
test('native sharing carries the exact departure and does not navigate or copy unnecessarily', async ({ page }) => {
|
||
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
|
||
await page.addInitScript(() => {
|
||
(window as any).shared = []; (window as any).copied = [];
|
||
Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { (window as any).shared.push(data); } });
|
||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
|
||
});
|
||
await page.goto('/?demo=1&city=1&stop=101&extra=private');
|
||
const originalLink = await page.locator('.departureEntry').first().getByRole('link').getAttribute('href');
|
||
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
|
||
const shared = await page.evaluate(() => (window as any).shared);
|
||
expect(shared).toHaveLength(1);
|
||
expect(shared[0].text).toContain('Площадь Свободы'); expect(shared[0].text).toContain('12:04');
|
||
const params = new URL(shared[0].url).searchParams;
|
||
expect(params.get('city')).toBe('1'); expect(params.get('stop')).toBe('101'); expect(params.get('route')).toBe('2');
|
||
expect(params.get('at')).toBe(new URL(originalLink!, 'http://localhost').searchParams.get('at')); expect(params.get('extra')).toBeNull();
|
||
expect(await page.evaluate(() => (window as any).copied.length)).toBe(0);
|
||
await expect(page).toHaveURL(/extra=private/);
|
||
});
|
||
|
||
test('sharing cancellation stays silent and leaves clipboard unchanged', async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
(window as any).copies = 0;
|
||
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Cancelled', 'AbortError'); } });
|
||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { (window as any).copies++; } } });
|
||
});
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
const button = page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ });
|
||
await button.click(); await expect(button).toBeEnabled();
|
||
expect(await page.evaluate(() => (window as any).copies)).toBe(0);
|
||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.locator('.toast')).toHaveCount(0);
|
||
});
|
||
|
||
test('copy fallback opens the same trip and works from the hourly timetable route screen', async ({ page }) => {
|
||
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
|
||
await page.addInitScript(() => {
|
||
(window as any).copied = [];
|
||
Object.defineProperty(navigator, 'share', { configurable: true, value: undefined });
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
|
||
});
|
||
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
|
||
await page.locator('.hourDeparture').filter({ hasText: '12:00' }).click();
|
||
const button = page.getByRole('button', { name: 'Поделиться отправлением', exact: true });
|
||
await button.click();
|
||
await expect(page.locator('.toast')).toContainText('Ссылка на отправление скопирована');
|
||
const url = await page.evaluate(() => (window as any).copied[0]);
|
||
await page.goto(url);
|
||
await expect(page.locator('.selectedJourneyStop .journeyTime strong')).toHaveText('12:00');
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await button.click();
|
||
expect(await page.evaluate(() => (window as any).copied[0])).toBe(url);
|
||
});
|
||
|
||
test('blocked native sharing and clipboard offer a selectable manual link', async ({ page }, info) => {
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } });
|
||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } } });
|
||
});
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Поделиться отправлением', exact: true });
|
||
await expect(dialog).toBeVisible();
|
||
const input = dialog.getByLabel('Ссылка на отправление');
|
||
await expect(input).toBeFocused();
|
||
expect(await input.evaluate((element: HTMLInputElement) => element.selectionEnd! - element.selectionStart!)).toBe((await input.inputValue()).length);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await page.screenshot({ path: `test-results/share-link-${info.project.name}.png`, animations: 'disabled' });
|
||
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
|
||
});
|
||
|
||
test('nearby and all stops share rows, selection, typography and scrollbar styles', async ({ page, context }, info) => {
|
||
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783 });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
|
||
const nearby = page.getByLabel('Остановки рядом', { exact: true });
|
||
const all = page.getByLabel('Остановки', { exact: true });
|
||
await expect(nearby.locator('.stopItem')).toHaveCount(3);
|
||
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
|
||
await nearby.getByRole('button', { name: /Больница/ }).click();
|
||
await expect(all.locator('.activeStop')).toHaveText('Больница');
|
||
await expect(nearby.locator('.activeStop')).toContainText('Больница');
|
||
const styles = (element: HTMLElement) => {
|
||
const list = getComputedStyle(element), row = getComputedStyle(element.querySelector('.stopItem')!), name = getComputedStyle(element.querySelector('.stopCopy strong')!);
|
||
return { scrollbarWidth: list.scrollbarWidth, scrollbarColor: list.scrollbarColor, maxHeight: list.maxHeight, padding: row.padding, radius: row.borderRadius, minHeight: row.minHeight, font: name.fontSize, weight: name.fontWeight };
|
||
};
|
||
expect(await nearby.evaluate(styles)).toEqual(await all.evaluate(styles));
|
||
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
|
||
});
|