/** * Portrait PWA layout tests. * * Verifies that the Settings panel (and other list-pane views) are fully * accessible in portrait orientation when the app is running as a PWA * (standalone display mode). * * Because Playwright cannot set display-mode:standalone natively, we inject * a small CSS override that activates the portrait media query rule-set via * a data attribute, and also add the .portrait-pwa class that the JS watcher * would normally add. This exercises the same CSS paths that fire in the real * installed PWA. */ const { test, expect } = require('@playwright/test'); // Portrait dimensions (iPhone 12 / similar) const PORTRAIT_WIDTH = 390; const PORTRAIT_HEIGHT = 844; const LANDSCAPE_WIDTH = 844; const LANDSCAPE_HEIGHT = 390; /** * Inject an override so the portrait media-query CSS fires without needing * an actual installed PWA. We piggyback on a custom data attribute. */ async function enablePortraitPwaMode(page) { await page.addStyleTag({ content: ` /* Mirror (display-mode:standalone) and (orientation:portrait) rules for testing */ [data-portrait-pwa-test] .app { grid-template-columns: 1fr; } [data-portrait-pwa-test] .bottom-nav { display: flex; } [data-portrait-pwa-test] .sidebar-toggle { display: inline-flex; } [data-portrait-pwa-test] .sidebar { position: fixed; top:0; left:0; bottom:0; width: min(86vw, 300px); max-width:300px; z-index:100; border-right:1px solid var(--line-soft); box-shadow:var(--shadow); transform:translateX(-100%); transition:transform 0.28s var(--ease); } [data-portrait-pwa-test] .app.sidebar-open .sidebar { transform:translateX(0); } [data-portrait-pwa-test] .app.sidebar-open .sidebar-backdrop { display:block; opacity:1; } [data-portrait-pwa-test] .topbar { position:sticky; top:0; z-index:50; } [data-portrait-pwa-test] .body { flex-direction:column; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; } [data-portrait-pwa-test] .player-pane { flex:none; width:100%; overflow-y:visible; } [data-portrait-pwa-test] .player-stage { width:100%; aspect-ratio:16/9; } [data-portrait-pwa-test] .btn-row { flex-wrap:wrap; gap:7px; } [data-portrait-pwa-test] .now-meta { flex-direction:column; gap:12px; margin-top:14px; } [data-portrait-pwa-test] .np-actions { flex-wrap:wrap; gap:6px; } [data-portrait-pwa-test] .list-pane { flex:none; min-height:100%; width:100%; border-left:none; border-top:1px solid var(--line-soft); overflow-y:visible; overflow-x:hidden; padding-bottom: 60px; } [data-portrait-pwa-test] .cards { max-height:none; overflow-y:visible; padding-bottom:0; } [data-portrait-pwa-test] .settings { padding-bottom:20px; } `, }); await page.evaluate(() => { document.documentElement.setAttribute('data-portrait-pwa-test', '1'); // Also add the JS class that setupPortraitPwaWatcher would add const app = document.querySelector('.app'); if (app) app.classList.add('portrait-pwa'); }); } /** * Playwright cannot set display-mode:standalone, so isPortraitPWA() in app.js * would return false and the JS scroll behaviors (scrollListIntoViewPortrait, * scrollPlayerIntoViewPortrait) would no-op. Stub matchMedia for that one * query BEFORE app.js loads so the JS layer behaves as in the installed PWA. */ async function stubStandalonePortraitMediaQuery(page) { await page.addInitScript(() => { const orig = window.matchMedia.bind(window); window.matchMedia = (q) => { if (q.includes('display-mode: standalone') && q.includes('portrait')) { return { matches: true, media: q, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, }; } return orig(q); }; }); } test.describe('Portrait PWA layout', () => { test.beforeEach(async ({ page }) => { await page.setViewportSize({ width: PORTRAIT_WIDTH, height: PORTRAIT_HEIGHT }); await stubStandalonePortraitMediaQuery(page); await page.goto('/'); // Wait for app JS to initialise await page.waitForSelector('.app', { state: 'attached' }); await enablePortraitPwaMode(page); }); test('app starts in portrait without horizontal scroll', async ({ page }) => { const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth); const viewportWidth = await page.evaluate(() => window.innerWidth); expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 2); // allow 2px rounding }); test('Settings panel is accessible in portrait', async ({ page }) => { // Open the sidebar drawer (hamburger) await page.click('#sidebarToggle'); await page.waitForTimeout(350); // drawer animation // Navigate to Settings await page.click('[data-view="settings"]'); await page.waitForTimeout(300); // Settings content must exist inside the list pane const settingsEl = await page.locator('.settings'); await expect(settingsEl).toBeVisible(); // The settings element must not be clipped — its bounding box bottom // should be reachable (list-pane scrolls to accommodate it) const listPane = page.locator('.list-pane'); await expect(listPane).toBeVisible(); const listPaneBox = await listPane.boundingBox(); expect(listPaneBox).not.toBeNull(); expect(listPaneBox.height).toBeGreaterThan(50); // must have meaningful height }); test('Settings panel scrolls to bottom without overlap', async ({ page }) => { await page.click('#sidebarToggle'); await page.waitForTimeout(350); await page.click('[data-view="settings"]'); await page.waitForTimeout(300); // Scroll list-pane to the bottom await page.evaluate(() => { const lp = document.querySelector('.list-pane'); if (lp) lp.scrollTop = lp.scrollHeight; }); await page.waitForTimeout(100); // After scrolling, the last setting group should be visible const lastGroup = page.locator('.set-group').last(); await expect(lastGroup).toBeVisible(); }); test('navigation works while a track is playing (player pane taller than viewport)', async ({ page }) => { // Simulate the DOM state afterLoad() creates for a playing track: player // pane populated with controls, meta, up-next and related — together // taller than the viewport. This used to leave the list-pane below the // fold with .body overflow:hidden, so every nav tap looked dead. await page.evaluate(() => { document.getElementById('playerPane').classList.remove('empty'); document.getElementById('playerPlaceholder').classList.add('hidden'); document.getElementById('controls').classList.remove('hidden'); document.getElementById('nowPlayingMeta').classList.remove('hidden'); document.getElementById('npTitle').textContent = 'Test track'; document.getElementById('upnext').classList.remove('hidden'); document.getElementById('upnextList').innerHTML = '
queued
'.repeat(4); document.getElementById('relatedPanel').classList.remove('hidden'); document.getElementById('relatedList').innerHTML = ''.repeat(6); document.getElementById('miniBar').classList.remove('hidden'); }); // Sanity: the populated player pane really is taller than the viewport. const paneHeight = await page.evaluate( () => document.getElementById('playerPane').getBoundingClientRect().height ); expect(paneHeight).toBeGreaterThan(PORTRAIT_HEIGHT); // Navigate via the bottom nav. await page.click('.bottom-nav-btn[data-view="history"]'); await page.waitForTimeout(700); // smooth scroll settle // The view switched AND its pane is actually on-screen. await expect(page.locator('#listTitle')).toHaveText('History'); const box = await page.locator('.list-pane').boundingBox(); expect(box).not.toBeNull(); expect(box.y).toBeLessThan(PORTRAIT_HEIGHT / 2); // top of the page is visible expect(box.y + box.height).toBeGreaterThan(200); // and it has visible extent // Sidebar navigation must work the same way. await page.click('#sidebarToggle'); await page.waitForTimeout(350); await page.click('.nav-item[data-view="saved"]'); await page.waitForTimeout(700); await expect(page.locator('#listTitle')).toHaveText('Saved videos'); const box2 = await page.locator('.list-pane').boundingBox(); expect(box2.y).toBeLessThan(PORTRAIT_HEIGHT / 2); }); test('switching landscape to portrait restores layout', async ({ page }) => { // Start in landscape await page.setViewportSize({ width: LANDSCAPE_WIDTH, height: LANDSCAPE_HEIGHT }); await page.waitForTimeout(200); // Switch back to portrait await page.setViewportSize({ width: PORTRAIT_WIDTH, height: PORTRAIT_HEIGHT }); await page.waitForTimeout(300); // App should not have horizontal overflow const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth); const viewportWidth = await page.evaluate(() => window.innerWidth); expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 2); // List pane should be visible and have height const listPane = page.locator('.list-pane'); await expect(listPane).toBeVisible(); const box = await listPane.boundingBox(); expect(box.height).toBeGreaterThan(50); }); test('landscape mode is unaffected — sidebar column visible at wide viewport', async ({ page }) => { // Remove the portrait-pwa test overrides await page.evaluate(() => { document.documentElement.removeAttribute('data-portrait-pwa-test'); const app = document.querySelector('.app'); if (app) app.classList.remove('portrait-pwa'); }); // Switch to a wide desktop viewport await page.setViewportSize({ width: 1280, height: 800 }); await page.waitForTimeout(200); // In landscape desktop, the sidebar should be visible (grid column) const sidebar = page.locator('.sidebar'); await expect(sidebar).toBeVisible(); // list-pane should have a fixed width (452px desktop) const listPaneBox = await page.locator('.list-pane').boundingBox(); expect(listPaneBox.width).toBeGreaterThan(200); }); });