import { test, expect } from '@playwright/test'; test('first home visit teaches on the real page and dismissing is remembered', async ({ page }) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await page.goto('/?demo=1&city=1&stop=101'); const guide = page.locator('.featureDiscovery'); await expect(guide).toBeVisible(); await expect(guide.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker'); await expect(page.locator('[data-discovery="stop-picker"]')).toBeVisible(); await expect(page.locator('#departures')).toBeVisible(); await guide.getByRole('button', { name: 'Пропустить', exact: true }).click(); await expect(guide).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done'); await page.reload(); await expect(page.locator('#departures')).toBeVisible(); await expect(guide).toHaveCount(0); expect(errors).toEqual([]); }); test('all guide steps fit a narrow dark viewport, keep the target clear, and work from the keyboard', async ({ page }, info) => { await page.setViewportSize({ width: 320, height: 568 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.addInitScript(() => localStorage.setItem('ridebus:theme', 'dark')); await page.goto('/?demo=1&city=1&stop=101'); const guide = page.locator('.featureDiscovery'); const card = guide.locator('.discoveryCard'); const targets = ['stop-picker', 'departures', 'favorites', 'quick-trips', 'settings']; for (const [index, target] of targets.entries()) { await expect(card).toHaveAttribute('data-step', target); await expect.poll(async () => page.evaluate(() => { const hint = document.querySelector('.discoveryCard')!.getBoundingClientRect(); const light = document.querySelector('.discoverySpotlight')!.getBoundingClientRect(); const overlaps = hint.left < light.right && hint.right > light.left && hint.top < light.bottom && hint.bottom > light.top; return { fits: hint.left >= 0 && hint.top >= 0 && hint.right <= innerWidth && hint.bottom <= innerHeight, overlaps }; })).toEqual({ fits: true, overlaps: false }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await expect(card).toHaveCSS('animation-name', 'none'); await expect(card.getByRole('button', { name: index === 4 ? 'Понятно' : 'Далее', exact: true })).toBeInViewport({ ratio: 1 }); await expect(card.getByRole('button', { name: 'Пропустить подсказки', exact: true })).toHaveCSS('min-width', '44px'); if (index === 0) { await expect.poll(async () => card.evaluate(element => { const bounds = element.getBoundingClientRect(); const copy = element.querySelector('.discoveryCopy')!; const copyBounds = copy.getBoundingClientRect(); const description = copy.querySelector('p')!.getBoundingClientRect(); const actions = element.querySelector('.discoveryActions')!.getBoundingClientRect(); return copy.scrollHeight <= copy.clientHeight + 1 && description.bottom <= copyBounds.bottom + 1 && copyBounds.top >= bounds.top && copyBounds.bottom <= actions.top; })).toBe(true); await page.setViewportSize({ width: 320, height: 360 }); await expect(card.getByRole('button', { name: 'Далее', exact: true })).toBeInViewport({ ratio: 1 }); const body = card.locator('.discoveryCopy'); await expect.poll(async () => card.evaluate(element => { const copy = element.querySelector('.discoveryCopy')!; const overflowing = copy.scrollHeight > copy.clientHeight + 1; return overflowing === Boolean(element.querySelector('.discoveryScrollHint')); })).toBe(true); expect(await body.evaluate(element => element.getBoundingClientRect().bottom <= element.closest('.discoveryCard')!.querySelector('.discoveryActions')!.getBoundingClientRect().top)).toBe(true); const dimensions = await body.evaluate(element => ({ scroll: element.scrollHeight, visible: element.clientHeight })); if (dimensions.scroll > dimensions.visible + 1) { await expect(card.locator('.discoveryScrollHint')).toBeVisible(); await expect(body).toHaveAttribute('role', 'region'); await body.evaluate(element => { element.scrollTop = element.scrollHeight; }); expect(await body.evaluate(element => element.scrollTop)).toBeGreaterThan(0); } else { await expect(card.locator('.discoveryScrollHint')).toHaveCount(0); expect(await body.evaluate(element => element.querySelector('p')!.getBoundingClientRect().bottom <= element.getBoundingClientRect().bottom + 1)).toBe(true); } await page.setViewportSize({ width: 320, height: 568 }); await expect(card.locator('.discoveryScrollHint')).toHaveCount(0); } if (index === 2) { await page.keyboard.press('ArrowLeft'); await expect(card).toHaveAttribute('data-step', 'departures'); await page.keyboard.press('ArrowRight'); await expect(card).toHaveAttribute('data-step', target); } if (index === 0 || index === 4) await page.screenshot({ path: `test-results/discovery-${target}-${info.project.name}.png`, animations: 'disabled' }); await page.keyboard.press('ArrowRight'); } await expect(guide).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done'); }); test('guide can be replayed from settings and Escape dismisses it', async ({ page }, info) => { await page.addInitScript(() => localStorage.setItem('ridebus:discovery:v1', 'done')); await page.goto('/settings?demo=1'); await expect(page.locator('.featureDiscovery')).toHaveCount(0); await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click(); await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker'); await page.keyboard.press('Escape'); await expect(page.locator('.featureDiscovery')).toHaveCount(0); const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' }); await nav.getByRole('link', { name: 'Настройки', exact: true }).click(); await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click(); await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker'); }); test('blocked storage does not trap visitors in the guide or prevent a manual replay', async ({ page }, info) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await page.addInitScript(() => { Storage.prototype.getItem = () => { throw new DOMException('Blocked', 'SecurityError'); }; Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'SecurityError'); }; }); await page.goto('/?demo=1'); await expect(page.locator('.featureDiscovery')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.locator('.featureDiscovery')).toHaveCount(0); const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' }); await nav.getByRole('link', { name: 'Настройки', exact: true }).click(); await nav.getByRole('link', { name: 'Главная', exact: true }).click(); await expect(page.locator('.featureDiscovery')).toHaveCount(0); await nav.getByRole('link', { name: 'Настройки', exact: true }).click(); await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click(); await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker'); expect(errors).toEqual([]); });