Files
iptvnator/apps/web-e2e/src/mobile-layout.e2e.ts
T

270 lines
11 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* Mobile Layout Smoke Tests
*
* PR #1326 made the workspace usable on phone-sized screens (issue #1100);
* the follow-up drawer PR turned the phone context panel into an off-canvas
* drawer. These tests pin the invariants that regressed before:
*
* 1. No horizontal overflow — document.documentElement.scrollWidth stays
* within the viewport on dashboard, Xtream VOD/live, and settings.
* 2. The workspace rail links render inside the 52px top bar instead of
* stacking downwards over the header.
* 3. On a portal route the context panel is an off-canvas drawer: hidden
* by default so the content keeps the full viewport width, opened from
* the header toggle (winning over the persisted desktop inline width),
* and closed again by picking a category or tapping the backdrop.
* 4. Settings keeps its drawer toggle beside the header Back (the drawer
* holds the sections), and the section list scrolls inside the drawer.
* On a portal list, the header's history Back yields to the toggle.
* 5. On a 640x360 landscape phone the live route keeps the channel
* sidebar at least 72px tall and the player container inside the
* viewport.
*
* Conventions: docs/architecture/iptvnator-ui-guidelines.md, "Phone Layout"
* (640px breakpoint, resizable rails stack full-width via `!important`).
*
* Tag: @mobile — run only this spec with:
* pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts
*/
const PHONE = { width: 375, height: 812 };
const LANDSCAPE_PHONE = { width: 640, height: 360 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function expectNoHorizontalOverflow(
page: Page,
viewportWidth: number
): Promise<void> {
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewportWidth);
}
async function boxOf(
locator: Locator
): Promise<{ x: number; y: number; width: number; height: number }> {
const box = await locator.boundingBox();
expect(box, 'expected element to have a bounding box').not.toBeNull();
return box as NonNullable<typeof box>;
}
/**
* Before #1326 the nested `.rail-links` list kept `flex-direction: column`
* inside the horizontal phone bar, so links 2..n were laid out below the
* 52px row and overlapped the header. Horizontal overflow inside the bar
* is fine — the bar scrolls sideways by design — so only y is asserted.
*/
async function expectRailLinksInsideTopBar(page: Page): Promise<void> {
const rail = page.locator('.app-rail');
await expect(rail).toBeVisible();
const railBox = await boxOf(rail);
// The shell grid gives the rail row 52px on phones.
expect(railBox.height).toBeLessThanOrEqual(60);
await expect(page.locator('.rail-links').first()).toHaveCSS(
'flex-direction',
'row'
);
const links = page.locator('.app-rail a:visible');
const count = await links.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const linkBox = await boxOf(links.nth(i));
expect(linkBox.y).toBeGreaterThanOrEqual(railBox.y - 1);
expect(linkBox.y + linkBox.height).toBeLessThanOrEqual(
railBox.y + railBox.height + 1
);
}
}
// ---------------------------------------------------------------------------
// Portrait phone — no imported sources needed, app boots at phone size
// ---------------------------------------------------------------------------
test.describe('portrait phone 375x812', () => {
test.use({ viewport: PHONE });
test('@mobile dashboard fits the viewport and keeps rail links in the top bar', async ({
page,
}) => {
await page.goto('/workspace/dashboard');
await expect(page.locator('.app-rail')).toBeVisible();
await expectNoHorizontalOverflow(page, PHONE.width);
await expectRailLinksInsideTopBar(page);
});
test('@mobile settings keeps the drawer toggle beside the header Back and the section list inside the drawer', async ({
page,
}) => {
await page.goto('/workspace/settings');
// Back is the header's leading button. The drawer holds the section
// list, so its toggle stays beside Back instead of giving way.
const back = page.locator('[data-test-id="workspace-header-back"]');
const toggle = page.locator('[data-test-id="context-drawer-toggle"]');
await expect(back).toBeVisible();
await expect(toggle).toBeVisible();
expect((await boxOf(back)).x).toBeLessThan((await boxOf(toggle)).x);
// The phone context panel is an off-canvas drawer: hidden until the
// header toggle opens it, so the settings content owns the pane.
const panel = page.locator('.context-panel--settings');
await expect(panel).toBeHidden();
await toggle.click();
await expect(panel).toBeVisible();
// Narrower than the viewport so the backdrop stays tappable, and
// wider than the persisted desktop inline width would leave it.
const panelBox = await boxOf(panel);
expect(panelBox.width).toBeGreaterThanOrEqual(300);
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
// Tapping the backdrop (right of the drawer) closes it.
await page
.locator('[data-test-id="context-drawer-backdrop"]')
.click({ position: { x: PHONE.width - 10, y: 400 } });
await expect(panel).toBeHidden();
await expectNoHorizontalOverflow(page, PHONE.width);
});
});
// ---------------------------------------------------------------------------
// Xtream portal routes — the portal is imported at the default desktop
// viewport (which persists desktop rail widths, the exact regression
// scenario from #1100), then the window shrinks to phone size.
// ---------------------------------------------------------------------------
test.describe('xtream portal routes on a phone', () => {
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
await addXtreamPortal(page, 'Mobile Layout Portal');
});
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
page,
}) => {
// The import navigated here from the dashboard, so the header offers
// the history Back on wide screens...
const back = page.locator('[data-test-id="workspace-header-back"]');
await expect(back).toBeVisible();
await page.setViewportSize(PHONE);
// ...and on a phone it yields to the drawer toggle, the list's only
// way into its categories.
await expect(back).toBeHidden();
await expect(
page.locator('[data-test-id="context-drawer-toggle"]')
).toBeVisible();
// Hidden by default — the content owns the full pane. This is the
// successor to the #1326 stacked layout, which left the content
// only the leftover under a 30vh panel.
const panel = page.locator('.context-panel');
await expect(panel).toBeHidden();
const content = page.locator('main.workspace-content');
const contentWidth = await content.evaluate((el) => el.clientWidth);
expect(contentWidth).toBeGreaterThanOrEqual(PHONE.width - 20);
// The header toggle slides the drawer in. The persisted desktop
// width is written as an inline style; the drawer rule must win
// with `width: 100% !important` of its ~320px surface, otherwise
// the panel keeps its desktop width fraction.
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(300);
// Picking a category both filters the route and closes the drawer.
const firstCategory = page
.locator('.context-panel .category-item')
.first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
await expect(panel).toBeHidden();
await expectNoHorizontalOverflow(page, PHONE.width);
await expectRailLinksInsideTopBar(page);
});
test('@mobile @xtream landscape live route keeps the channel list and the player inside the viewport', async ({
page,
}) => {
// Select a category at desktop width so the channel sidebar renders
// (`showLiveChannelSidebar` requires a selected category). On the
// live root the click updates store state without navigating, so the
// sidebar appearing is the completion signal — not a URL change.
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
const firstCategory = page
.locator('.context-panel .category-item')
.first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
const sidebar = page.locator('app-live-stream-layout .sidebar');
await expect(sidebar).toBeVisible();
// The workspace header carries the live rail toggle at desktop
// width only; on the phone the rail is a bottom drawer with its own
// toggle and the header has no room for another permanent icon.
const headerRailToggle = page.locator(
'app-workspace-shell-header .header-sidebar-toggle'
);
await expect(headerRailToggle).toBeVisible();
await page.setViewportSize(LANDSCAPE_PHONE);
await expect(headerRailToggle).toBeHidden();
// The sidebar stacks at full width above the player.
await expect
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(LANDSCAPE_PHONE.width - 2);
// Before the follow-up fix in #1326 the player's 240px floor drove
// the still-expanded sidebar to zero height on a 640x360 screen,
// leaving no way to pick another channel.
const sidebarBox = await boxOf(sidebar);
expect(sidebarBox.height).toBeGreaterThanOrEqual(71);
// ...and pushed the layout past the viewport. The player container
// has to end inside it.
const contentBox = await boxOf(
page.locator('app-live-stream-layout .content-container')
);
expect(contentBox.y + contentBox.height).toBeLessThanOrEqual(
LANDSCAPE_PHONE.height + 1
);
expect(contentBox.width).toBeLessThanOrEqual(LANDSCAPE_PHONE.width);
await expectNoHorizontalOverflow(page, LANDSCAPE_PHONE.width);
});
});