import { expect, type Page, test } from '@playwright/test'; import { ALL_VIDEO_PAGES, type PageEntry, VIDEO_PAGES } from '../fixtures/media'; import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; import { PlayerPage } from '../page-objects/player'; const UI_VIDEO_PAGES = VIDEO_PAGES.filter(({ media }) => media === 'video'); function getMediaVolume(page: Page): Promise { return page.evaluate((selector) => { const media = document.querySelector(selector) as HTMLMediaElement | null; const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; return actual?.volume ?? 1; }, SELECTORS.media); } async function mockPresentation(page: Page): Promise { await page.addInitScript(() => { let fullscreenElement: Element | null = null; let pipElement: Element | null = null; Object.defineProperties(document, { fullscreenElement: { configurable: true, get: () => fullscreenElement }, fullscreenEnabled: { configurable: true, get: () => true }, pictureInPictureElement: { configurable: true, get: () => pipElement }, pictureInPictureEnabled: { configurable: true, get: () => true }, }); Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: async function requestFullscreen(this: HTMLElement) { fullscreenElement = this; document.dispatchEvent(new Event('fullscreenchange')); }, }); Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: async () => { fullscreenElement = null; document.dispatchEvent(new Event('fullscreenchange')); }, }); Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', { configurable: true, value: async function requestPictureInPicture(this: HTMLVideoElement) { pipElement = this; this.dispatchEvent(new Event('enterpictureinpicture')); return {}; }, }); Object.defineProperties(HTMLVideoElement.prototype, { webkitPresentationMode: { configurable: true, get: function webkitPresentationMode(this: HTMLVideoElement) { return pipElement === this ? 'picture-in-picture' : 'inline'; }, }, webkitSetPresentationMode: { configurable: true, value: function webkitSetPresentationMode(this: HTMLVideoElement, mode: string) { const wasPip = pipElement === this; pipElement = mode === 'picture-in-picture' ? this : null; if (!wasPip && pipElement === this) { this.dispatchEvent(new Event('enterpictureinpicture')); } else if (wasPip && pipElement !== this) { this.dispatchEvent(new Event('leavepictureinpicture')); } }, }, }); Object.defineProperty(document, 'exitPictureInPicture', { configurable: true, value: async () => { const video = pipElement; pipElement = null; video?.dispatchEvent(new Event('leavepictureinpicture')); }, }); }); } for (const { name, path, skipBrowsers } of ALL_VIDEO_PAGES as readonly PageEntry[]) { const rateMenu = !path.includes('/cdn-video') && !path.includes('/ejected'); test.describe(`Video Controls — ${name}`, () => { test.skip(({ browserName }) => { return skipBrowsers?.includes(browserName as 'chromium' | 'webkit' | 'firefox') ?? false; }, 'Skipped on this browser'); let player: PlayerPage; test.beforeEach(async ({ page }) => { player = new PlayerPage(page); await page.goto(path); await player.waitForMediaReady(); }); // --- Grouped: control presence & attributes (one navigation) --- test('all controls are present with correct attributes', async () => { await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel); await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability); // PiP is unsupported on WebKit and the button receives the `hidden` attribute. // Only assert `data-availability` when the pip button is visible. if (await player.pipButton.isVisible()) { await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability); } await expect(player.settingsButton).toBeAttached(); await expect(player.duration).not.toHaveText(''); await player.showControls(); await expect(player.controls).toBeAttached(); }); // --- Play / Pause --- test('play button starts playback', async () => { await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); await player.play(); await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused); }); test('play button pauses playback', async () => { await player.play(); await player.pause(); await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); }); // --- Time Slider --- test('time slider allows seeking', async ({ page }) => { await player.seekTo(50); await expect .poll( async () => { return page.evaluate((selector) => { const el = document.querySelector(selector); const media = (el?.querySelector?.('video') as HTMLMediaElement) ?? (el as HTMLMediaElement); return media?.currentTime ?? 0; }, SELECTORS.media); }, { timeout: 10_000 } ) .toBeGreaterThan(0); }); test('time slider shows interactive state on hover', async () => { await player.hoverTimeSlider(50); await expect(player.timeSlider).toHaveAttribute(DATA_ATTRS.pointing, ''); }); // --- Mute --- test('mute button toggles mute', async () => { await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.muted, ''); await player.muteButton.click(); await expect(player.muteButton).not.toHaveAttribute(DATA_ATTRS.muted); await player.muteButton.click(); await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.muted, ''); }); // --- Playback Rate --- (rateMenu ? test : test.skip)('playback rate menu changes selected rate', async () => { const initialRate = await player.getPlaybackRate(); await player.selectAlternativePlaybackRate(); await expect.poll(async () => player.getPlaybackRate()).not.toBe(initialRate); }); // --- Poster --- test('poster hides after playback starts', async () => { await expect(player.poster).toBeAttached(); await player.play(); await expect(player.poster).not.toHaveAttribute(DATA_ATTRS.visible); }); }); } for (const { name, path } of UI_VIDEO_PAGES) { test.describe(`Video Controls — ${name} UI`, () => { let player: PlayerPage; test.beforeEach(async ({ page }) => { await mockPresentation(page); player = new PlayerPage(page); await page.goto(path); await player.waitForMediaReady(); }); test('volume slider changes volume', async ({ page }) => { await player.showControls(); await player.muteButton.hover(); await expect(player.volumeSlider).toBeVisible(); const box = await player.volumeSlider.boundingBox(); if (!box) throw new Error('Volume slider not visible'); await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.75); await expect.poll(() => getMediaVolume(page)).toBeLessThan(0.5); }); test('buffering indicator follows waiting state', async ({ page }) => { await player.play(); await page.evaluate((selector) => { const media = document.querySelector(selector) as HTMLMediaElement | null; const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; if (!actual) return; Object.defineProperties(actual, { paused: { configurable: true, get: () => false }, readyState: { configurable: true, get: () => HTMLMediaElement.HAVE_CURRENT_DATA }, }); actual.dispatchEvent(new Event('waiting')); }, SELECTORS.media); await expect(player.bufferingIndicator).toHaveAttribute(DATA_ATTRS.visible, '', { timeout: 2_000 }); await page.evaluate((selector) => { const media = document.querySelector(selector) as HTMLMediaElement | null; const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; if (!actual) return; Object.defineProperty(actual, 'readyState', { configurable: true, get: () => HTMLMediaElement.HAVE_ENOUGH_DATA, }); actual.dispatchEvent(new Event('playing')); }, SELECTORS.media); await expect(player.bufferingIndicator).not.toHaveAttribute(DATA_ATTRS.visible); }); test('play button shows its tooltip on hover', async ({ page }) => { await player.showControls(); await player.playButton.hover(); await expect(player.playTooltip).toHaveAttribute(DATA_ATTRS.open, '', { timeout: 2_000 }); }); test('play button reflects ended playback', async ({ page }) => { await page.evaluate((selector) => { const media = document.querySelector(selector) as HTMLMediaElement | null; const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; if (!actual) return; Object.defineProperties(actual, { ended: { configurable: true, get: () => true }, paused: { configurable: true, get: () => true }, }); actual.dispatchEvent(new Event('ended')); }, SELECTORS.media); await expect(player.playButton).toHaveAttribute(DATA_ATTRS.ended, ''); }); test('fullscreen button toggles fullscreen', async () => { await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability, 'available'); await player.fullscreenButton.click(); await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.fullscreen, ''); await player.fullscreenButton.click(); await expect(player.fullscreenButton).not.toHaveAttribute(DATA_ATTRS.fullscreen); }); test('PiP button toggles picture-in-picture', async () => { await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability, 'available'); await player.pipButton.click(); await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.pip, ''); await player.pipButton.click(); await expect(player.pipButton).not.toHaveAttribute(DATA_ATTRS.pip); }); }); }