const { test, expect } = require('@playwright/test'); const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture'); const picks = Array.from({ length: 8 }, (_, i) => ({ id: 'recommend' + String(i).padStart(2, '0'), title: `Piano session ${i + 1} — a little room to listen`, channel: 'Studio sessions', thumbnail: '/fixture-cover.svg', duration: 240, reason: i < 2 ? 'One of your most played' : 'More from Studio sessions', })); async function recommendations(page, result = picks) { await page.route('**/api/recommendations?**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: result }) })); await page.evaluate(() => Recommendations.update(true)); await expect(page.locator('#homeRecommendations .rec-item')).toHaveCount(result.length); } for (const width of [320, 390, 1440]) { test(`home recommendation corner is readable and reachable at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await openClassic(page, { settings: width === 320 ? { textSize: 'xl' } : {} }); await recommendations(page); await expect(page.getByRole('heading', { name: 'Recommended for you' })).toBeVisible(); const result = await page.evaluate(() => { const host = document.querySelector('#homeRecommendations'); const box = host.getBoundingClientRect(); const overflow = [...host.querySelectorAll('*')].some(e => e.getBoundingClientRect().right > box.right + 1); function contrast(el) { const rgb = s => s.match(/[\d.]+/g).slice(0, 3).map(Number).map(n => { n /= 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; }); const luminance = s => { const a = rgb(s); return .2126 * a[0] + .7152 * a[1] + .0722 * a[2]; }; const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-1').trim(); const probe = document.createElement('span'); probe.style.color = bg; document.body.appendChild(probe); const b = luminance(getComputedStyle(probe).color); probe.remove(); const f = luminance(getComputedStyle(el).color); return (Math.max(f, b) + .05) / (Math.min(f, b) + .05); } return { overflow, width: box.width, contrast: contrast(host.querySelector('.rec-reason')) }; }); expect(result.overflow).toBe(false); expect(result.width).toBeGreaterThan(200); expect(result.contrast).toBeGreaterThanOrEqual(4.5); await page.locator('#homeRecommendations .rec-heading').evaluate(el => el.scrollIntoView({ block: 'start' })); await page.screenshot({ path: `/tmp/ytplayer-recommendations-${width}.png`, fullPage: true }); const last = page.locator('#homeRecommendations .rec-actions button').last(); await last.scrollIntoViewIfNeeded(); await expect(last).toBeInViewport(); }); } test('recommendations can play, queue, and open playlist actions', async ({ page }) => { await openClassic(page); await recommendations(page); await page.getByRole('button', { name: 'Queue ' + picks[0].title, exact: true }).click(); expect(await page.evaluate(() => data.queue.some(v => v.id === 'recommend00'))).toBe(true); await page.getByRole('button', { name: 'More actions for ' + picks[0].title }).click(); await expect(page.locator('#modal')).toBeVisible(); await page.evaluate(() => closeModal()); await page.evaluate(() => { window.__played = null; playFromList = (list, index, source) => { window.__played = { id: list[index].id, source }; }; }); await page.getByRole('button', { name: 'Play ' + picks[1].title, exact: true }).click(); expect(await page.evaluate(() => window.__played)).toEqual({ id: picks[1].id, source: 'recommendations' }); }); test('Search menu restores recommendations during playback on desktop and mobile', async ({ page }) => { await openClassic(page, { populated: true }); await recommendations(page); await showPlayingFixture(page); await page.locator('.nav-item[data-view="search"]').click(); await expect(page.locator('#homeRecommendations')).toBeVisible(); await expect(page.locator('#miniBar')).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('#bottomNav [data-view="history"]').click(); await page.locator('#bottomNav [data-view="search"]').click(); await expect(page.locator('#homeRecommendations')).toBeVisible(); }); test('a saved browser search submits metadata even when no server search is needed', async ({ page }) => { await openClassic(page); const collected = []; let searched = 0; await page.route('**/api/catalog/collect', route => { collected.push(route.request().postDataJSON()); return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); }); await page.route('**/api/search?**', route => { searched++; return route.fulfill({ contentType: 'application/json', body: '{"ok":true,"results":[]}' }); }); await page.evaluate(async picks => { SearchLibrary.put('piano fixture', picks, Date.now()); await SearchLibrary.get('piano fixture'); }, picks); await expect.poll(() => collected.length).toBeGreaterThan(0); collected.length = 0; await page.locator('#searchInput').fill('piano fixture'); await page.locator('#searchForm').evaluate(el => el.requestSubmit()); await expect.poll(() => collected.length).toBeGreaterThan(0); expect(collected[0].cards[0].id).toBe(picks[0].id); expect(searched).toBe(0); }); test('offline uses saved recommendations and queues metadata for reconnect', async ({ page }) => { await openClassic(page); await recommendations(page); await page.route('**/api/recommendations?**', route => route.abort('failed')); await page.route('**/api/catalog/collect', route => route.abort('failed')); await page.evaluate(async picks => { await Recommendations.update(true); SearchLibrary.put('offline discovery', picks, Date.now()); }, picks); await expect(page.getByText('Saved picks · reconnect to refresh')).toBeVisible(); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('ytpCatalogPending')).includes('offline discovery'))).toBe(true); let received = false; await page.route('**/api/catalog/collect', route => { received = true; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); }); await page.evaluate(() => window.dispatchEvent(new Event('online'))); await expect.poll(() => received).toBe(true); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('ytpCatalogPending')).length)).toBe(0); }); test('cold start and failed recommendations keep search usable', async ({ page }) => { await openClassic(page); await expect(page.getByText('Search or play a few videos to start discovering recommendations here.')).toBeVisible(); await page.route('**/api/recommendations?**', route => route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"offline"}' })); await page.evaluate(() => Recommendations.update(true)); await expect(page.getByText('Recommendations are unavailable. You can still search or play your playlists.')).toBeVisible(); await expect(page.locator('#searchInput')).toBeEditable(); }); test('switching profiles reloads recommendations and scopes saved picks', async ({ page }) => { await openClassic(page); await recommendations(page); const profiles = []; await page.route('**/api/recommendations?**', route => { profiles.push(new URL(route.request().url()).searchParams.get('profile')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [picks[2]] }) }); }); await page.evaluate(async () => { data.profile = { name: 'test-listener' }; await Recommendations.update(true); }); await expect.poll(() => profiles.includes('test-listener')).toBe(true); await expect.poll(() => page.evaluate(() => !!localStorage.getItem('ytpRecommendations:profile:test-listener'))).toBe(true); await expect(page.locator('#homeRecommendations .rec-item')).toHaveCount(1); });