import { test, expect, type Page } from './fixtures'; async function seedTrips(page: Page, count: number) { await page.evaluate(async count => { const request = indexedDB.open('ridebus-pwa'); const database = await new Promise((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((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 < innerHeight - 100)).toBe(true); 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('/stops?demo=1&city=1&stop=101'); const all = page.locator('.stopsList[aria-label="Остановки"]'); await expect(all.locator('.stopItem')).toHaveCount(5); 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 }; }; const normalStyles = await all.evaluate(styles); await page.getByRole('button', { name: 'Рядом', exact: true }).click(); const nearby = page.getByLabel('Остановки рядом', { exact: true }); await expect(nearby.locator('.stopItem')).toHaveCount(3); await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м'); expect(await nearby.evaluate(styles)).toEqual(normalStyles); await nearby.getByRole('button', { name: /Больница/ }).click(); await expect(nearby.locator('.activeStop')).toContainText('Больница'); await page.getByRole('button', { name: 'Все остановки', exact: true }).click(); await expect(all.locator('.activeStop')).toHaveText('Больница'); await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' }); });