Files
ytplayer/tests/portrait-pwa.spec.js

240 lines
10 KiB
JavaScript

/**
* 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 =
'<div class="upnext-item">queued</div>'.repeat(4);
document.getElementById('relatedPanel').classList.remove('hidden');
document.getElementById('relatedList').innerHTML =
'<div class="related-item">related</div>'.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);
});
});