fix(test): remove seek tests, add missing tests (#1892)
@@ -69,7 +69,7 @@ export const SELECTORS = {
|
||||
// Sliders
|
||||
// HTML: <media-time-slider>, React: horizontal .media-slider inside .media-time-controls
|
||||
timeSlider: 'media-time-slider, .media-time-controls .media-slider',
|
||||
volumeSlider: 'media-volume-slider, .media-slider[data-orientation="vertical"]',
|
||||
volumeSlider: 'media-volume-slider, .media-popover--volume .media-slider',
|
||||
sliderThumb: 'media-slider-thumb, .media-slider__thumb',
|
||||
|
||||
// Display elements
|
||||
@@ -81,11 +81,10 @@ export const SELECTORS = {
|
||||
'[data-type="duration"].media-time',
|
||||
'[data-type="remaining"].media-time',
|
||||
].join(', '),
|
||||
poster: 'media-poster, img[data-visible]',
|
||||
poster: 'media-poster, img[data-loaded]',
|
||||
bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator',
|
||||
thumbnail: 'media-slider-thumbnail, .media-thumbnail__image',
|
||||
|
||||
// Popover & tooltip
|
||||
tooltip: 'media-tooltip, .media-tooltip',
|
||||
popover: 'media-popover, .media-popover',
|
||||
errorDialog: 'media-error-dialog, .media-error',
|
||||
|
||||
@@ -98,6 +98,14 @@ export class PlayerPage {
|
||||
return this.page.locator(SELECTORS.thumbnail).first();
|
||||
}
|
||||
|
||||
get tooltip(): Locator {
|
||||
return this.page.locator(SELECTORS.tooltip);
|
||||
}
|
||||
|
||||
get playTooltip(): Locator {
|
||||
return this.tooltip.filter({ hasText: 'Play' }).first();
|
||||
}
|
||||
|
||||
get popover(): Locator {
|
||||
return this.page.locator(SELECTORS.popover).first();
|
||||
}
|
||||
|
||||
@@ -37,4 +37,24 @@ test.describe('Captions', () => {
|
||||
const options = page.locator(SELECTORS.activeMenuOptions);
|
||||
await expect(options).toHaveCount(2, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('captions button toggles captions', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
const video = document.querySelector('video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.label = 'English';
|
||||
track.srclang = 'en';
|
||||
track.src = `data:text/vtt,${encodeURIComponent('WEBVTT\n\n00:00:00.000 --> 00:00:30.000\nTest caption')}`;
|
||||
video.appendChild(track);
|
||||
});
|
||||
|
||||
await player.showControls();
|
||||
await player.captionsButton.click();
|
||||
await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.active, '');
|
||||
await player.captionsButton.click();
|
||||
await expect(player.captionsButton).not.toHaveAttribute(DATA_ATTRS.active);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,89 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { ALL_VIDEO_PAGES, type PageEntry } from '../fixtures/media';
|
||||
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';
|
||||
|
||||
for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly PageEntry[]) {
|
||||
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 }) => {
|
||||
@@ -20,10 +100,6 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa
|
||||
// --- Grouped: control presence & attributes (one navigation) ---
|
||||
|
||||
test('all controls are present with correct attributes', async () => {
|
||||
await expect(player.seekForward).toBeAttached();
|
||||
await expect(player.seekForward).toHaveAttribute(DATA_ATTRS.direction, 'forward');
|
||||
await expect(player.seekBackward).toBeAttached();
|
||||
await expect(player.seekBackward).toHaveAttribute(DATA_ATTRS.direction, 'backward');
|
||||
await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel);
|
||||
await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability);
|
||||
await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability);
|
||||
@@ -47,15 +123,6 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa
|
||||
await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, '');
|
||||
});
|
||||
|
||||
// --- Seek ---
|
||||
|
||||
test('seek forward advances playback', async () => {
|
||||
test.skip(media === 'native-hls-video', 'seek before playback not yet supported');
|
||||
|
||||
await player.seekForward.click();
|
||||
await expect(player.playButton).toHaveAttribute(DATA_ATTRS.started, '');
|
||||
});
|
||||
|
||||
// --- Time Slider ---
|
||||
|
||||
test('time slider allows seeking', async ({ page }) => {
|
||||
@@ -102,18 +169,112 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa
|
||||
|
||||
// --- Poster ---
|
||||
|
||||
test('poster hides after playback starts', async ({ page }) => {
|
||||
test('poster hides after playback starts', async () => {
|
||||
await expect(player.poster).toBeAttached();
|
||||
await player.play();
|
||||
|
||||
const posterVisible = await page.evaluate((attr) => {
|
||||
const htmlPoster = document.querySelector('media-poster');
|
||||
if (htmlPoster) return htmlPoster.hasAttribute(attr);
|
||||
await expect(player.poster).not.toHaveAttribute(DATA_ATTRS.visible);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const reactPoster = document.querySelector(`img[${attr}]`);
|
||||
return !!reactPoster;
|
||||
}, DATA_ATTRS.visible);
|
||||
for (const { name, path } of UI_VIDEO_PAGES) {
|
||||
test.describe(`Video Controls — ${name} UI`, () => {
|
||||
let player: PlayerPage;
|
||||
|
||||
expect(posterVisible).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -41,9 +41,8 @@ for (const { name, path } of VISUAL_PAGES) {
|
||||
await player.hoverTimeSlider(50);
|
||||
|
||||
// Wait for thumbnail to finish loading (deterministic, no fixed timeout)
|
||||
const thumbnail = page.locator(SELECTORS.thumbnail).first();
|
||||
await expect(thumbnail).toBeAttached({ timeout: 10_000 });
|
||||
await expect(thumbnail).not.toHaveAttribute(DATA_ATTRS.loading, { timeout: 10_000 });
|
||||
await expect(player.thumbnail).toBeAttached({ timeout: 10_000 });
|
||||
await expect(player.thumbnail).not.toHaveAttribute(DATA_ATTRS.loading, { timeout: 10_000 });
|
||||
|
||||
await expect(player.playerRoot).toHaveScreenshot(`video-${name.toLowerCase()}-storyboard.png`);
|
||||
});
|
||||
|
||||
@@ -6,18 +6,6 @@ media-tooltip-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.media-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
:host {
|
||||
/* `display:grid` fixes a weird issue with Safari when setting aspect-ratio */
|
||||
display: grid;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "../../../shared/css/sr-only.css";
|
||||
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "../../../shared/css/sr-only.css";
|
||||
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.media-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||