mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
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<number> {
|
|
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<void> {
|
|
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);
|
|
});
|
|
});
|
|
}
|