Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions apps/e2e/fixtures/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Cross-renderer selectors that work for both HTML (Web Components) and React.
*
* HTML uses custom element tags: `media-play-button`, `media-time-slider`, etc. React uses standard elements with CSS
* classes: `button.media-button--play`, etc.
* HTML uses custom element tags such as `media-play-button`. React CSS skins use the matching VJSC part classes, while
* source-owned Tailwind skins retain semantic roles, state attributes, and keyboard shortcuts. Legacy classes remain
* here only while stacked pre-cutover PRs use them.
Comment thread
cursor[bot] marked this conversation as resolved.
*
* Both renderers apply the **same data attributes** for state (`data-paused`, `data-muted`, etc.), which is what tests
* assert against.
Expand Down Expand Up @@ -43,25 +44,29 @@ export const SELECTORS = {
controls: 'media-controls-content, .media-controls',

// Buttons
playButton: 'media-play-button, .media-button--play',
seekBackward: 'media-seek-button[data-direction="backward"], .media-button--seek[data-direction="backward"]',
seekForward: 'media-seek-button[data-direction="forward"], .media-button--seek[data-direction="forward"]',
muteButton: 'media-mute-button, .media-button--mute',
fullscreenButton: 'media-fullscreen-button, .media-button--fullscreen',
pipButton: 'media-pip-button, .media-button--pip',
castButton: 'media-cast-button, .media-button--cast',
airPlayButton: 'media-airplay-button, .media-button--airplay',
captionsButton: 'media-captions-button, .media-button--captions',
playButton: 'media-play-button, .media-play-button, .media-button--play, button[aria-keyshortcuts~="Space"]',
seekBackward:
'media-seek-button[data-direction="backward"], .media-seek-button[data-direction="backward"], .media-button--seek[data-direction="backward"]',
seekForward:
'media-seek-button[data-direction="forward"], .media-seek-button[data-direction="forward"], .media-button--seek[data-direction="forward"]',
muteButton: 'media-mute-button, .media-mute-button, .media-button--mute',
fullscreenButton: 'media-fullscreen-button, .media-fullscreen-button, .media-button--fullscreen',
pipButton: 'media-pip-button, .media-pip-button, .media-button--pip',
castButton: 'media-cast-button, .media-cast-button, .media-button--cast',
airPlayButton: 'media-airplay-button, .media-airplay-button, .media-button--airplay',
captionsButton: 'media-captions-button, .media-captions-button, .media-button--captions',
playbackRateButton: [
withinControls('media-playback-rate-button'),
withinControls('.media-playback-rate-button'),
withinControls('.media-button--playback-rate'),
withinControls('button[aria-haspopup="menu"][aria-label^="Playback rate"]:not(.media-menu__item)'),
].join(', '),
playbackRateUncheckedOptions: unchecked(playbackRateOptions),
activeMenuOptions: `${activeSubmenu} ${option}`,
activeMenuPanel: `${activeSubmenu}.media-menu__panel`,
activeMenuPanel: `${activeSubmenu}:is(.media-menu-content, .media-menu__panel)`,
activeMenuUncheckedOptions: unchecked(`${activeSubmenu} ${option}`),
settingsButton: [
withinControls('.media-settings-menu-trigger'),
withinControls('.media-button--settings'),
withinControls('button[commandfor="settings-menu"]'),
withinControls('button[aria-label="Settings"]'),
Expand All @@ -71,9 +76,9 @@ 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-popover--volume .media-slider',
sliderThumb: 'media-slider-thumb, .media-slider__thumb',
timeSlider: 'media-time-slider, .media-time-slider, .media-time-controls .media-slider',
volumeSlider: 'media-volume-slider, .media-volume-slider, .media-popover--volume .media-slider',
sliderThumb: 'media-slider-thumb, .media-slider-thumb, .media-slider__thumb',

// Display elements
// HTML uses attribute `type`, React uses `data-type`
Expand All @@ -87,11 +92,12 @@ export const SELECTORS = {
timeToggle: 'media-time[toggle], time.media-time[role="button"]',
poster: 'media-poster, img[data-loaded]',
bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator',
thumbnail: 'media-slider-thumbnail, .media-thumbnail__image',
thumbnail:
'media-slider-thumbnail, .media-slider-thumbnail-image, .media-thumbnail__image, [role="img"]:has(> img[aria-hidden="true"])',

tooltip: 'media-tooltip, .media-tooltip',
popover: 'media-popover, .media-popover',
errorDialog: 'media-error-dialog, .media-dialog__popup',
popover: 'media-popover, .media-volume-popover, .media-menu-popup, .media-popover',
errorDialog: 'media-error-dialog, .media-dialog-popup, .media-dialog__popup',

// Media element — matches all renderer custom elements and native media
media: 'video, audio, hlsjs-video, hls-video, native-hls-video, dash-video, shaka-video, mux-video, mux-audio',
Expand Down
1 change: 0 additions & 1 deletion apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"@videojs/html": "workspace:*",
"@videojs/icons": "workspace:*",
"@videojs/react": "workspace:*",
"@videojs/skins": "workspace:*",
"@videojs/spf": "workspace:*",
"@videojs/store": "workspace:*",
"@videojs/utils": "workspace:*",
Expand Down
3 changes: 1 addition & 2 deletions apps/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,7 @@ export default defineConfig({
...(shouldStartSandboxServer
? [
{
command:
'pnpm --dir ../.. build:cdn && pnpm exec tsx scripts/setup.ts && pnpm exec vp dev --host --port 5299',
command: 'pnpm --dir ../.. dev:sandbox --port 5299',
cwd: '../sandbox',
port: 5299,
reuseExistingServer: !CI,
Expand Down
23 changes: 0 additions & 23 deletions apps/e2e/tests/sandbox-cdn-i18n.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,6 @@ const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299';

test.use({ trace: 'off' });

async function expectLTRControlOrder(scope: Page | Frame): Promise<void> {
const getX = async (selector: string): Promise<number> => {
const control = scope.locator(selector).first();

await expect(control).toBeVisible();
const box = await control.boundingBox();
if (!box) throw new Error(`Control has no bounding box: ${selector}`);

return box.x;
};
const [play, mute, settings, fullscreen] = await Promise.all([
getX(SELECTORS.playButton),
getX(SELECTORS.muteButton),
getX(SELECTORS.settingsButton),
getX(SELECTORS.fullscreenButton),
]);

expect(play).toBeLessThan(mute);
expect(mute).toBeLessThan(settings);
expect(settings).toBeLessThan(fullscreen);
}

async function getPreviewFrame(page: Page, path: string): Promise<Frame> {
await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`));
await expect
Expand Down Expand Up @@ -79,6 +57,5 @@ test.describe('Sandbox CDN i18n', () => {
await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await expect(page.locator('media-i18n')).toHaveCSS('direction', 'rtl');
await expectLTRControlOrder(page);
});
});
58 changes: 2 additions & 56 deletions apps/e2e/tests/sandbox-html-i18n.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,6 @@ async function expectSpanishPlayLabel(scope: Page | Frame): Promise<void> {
await expect(playButton).toHaveAttribute('aria-label', 'Reproducir', { timeout: 15_000 });
}

async function expectLTRControlOrder(scope: Page | Frame): Promise<void> {
const getX = async (selector: string): Promise<number> => {
const control = scope.locator(selector).first();

await expect(control).toBeVisible();
const box = await control.boundingBox();
if (!box) throw new Error(`Control has no bounding box: ${selector}`);

return box.x;
};
const [play, mute, settings, fullscreen] = await Promise.all([
getX(SELECTORS.playButton),
getX(SELECTORS.muteButton),
getX(SELECTORS.settingsButton),
getX(SELECTORS.fullscreenButton),
]);

expect(play).toBeLessThan(mute);
expect(mute).toBeLessThan(settings);
expect(settings).toBeLessThan(fullscreen);
}

async function expectLTRThumbnailCrop(page: Page): Promise<void> {
const slider = page.locator('[role="slider"]:visible').first();

await expect(slider).toBeVisible();
const box = await slider.boundingBox();
if (!box) throw new Error('Time slider is not visible');

await page.mouse.move(box.x + box.width * 0.75, box.y + box.height / 2);
const thumbnail = page.locator('media-slider-thumbnail, .media-thumbnail__image').first();

await expect(thumbnail).toBeAttached({ timeout: 15_000 });
await expect(thumbnail).not.toHaveAttribute('data-loading', { timeout: 15_000 });
await expect(thumbnail).toHaveAttribute('dir', 'ltr');

const crop = await thumbnail.evaluate((element) => {
const image = element.shadowRoot?.querySelector('img') ?? element.querySelector('img');
if (!image) return;

const hostBox = element.getBoundingClientRect();
const imageBox = image.getBoundingClientRect();
const transform = new DOMMatrix(getComputedStyle(image).transform);

return { actual: imageBox.left, expected: hostBox.left + transform.m41 };
});
if (!crop) throw new Error('Thumbnail image is not rendered');

expect(crop.actual).toBeCloseTo(crop.expected, 0);
}

function getPlayer(page: Page) {
return page
.locator('[role="group"]')
Expand Down Expand Up @@ -134,8 +83,6 @@ test.describe('Sandbox HTML i18n', () => {
const provider = page.locator('media-i18n');

await expect(provider.locator('video-skin')).toHaveCSS('direction', 'rtl');
await expectLTRControlOrder(page);
await expectLTRThumbnailCrop(page);
});
});

Expand All @@ -160,12 +107,11 @@ test.describe('Sandbox React i18n', () => {
await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await expect(page.locator('.media-skin--default.media-skin--video')).toHaveCSS('direction', 'rtl');
await expectLTRControlOrder(page);
await expectLTRThumbnailCrop(page);
});
});

test.describe('Sandbox RTL playback control order', () => {
// VJSC preserves locale direction but intentionally defers legacy physical control order; see vjsc/gaps.md.
test.describe.skip('Sandbox RTL playback control order', () => {
const cases = [
{ name: 'HTML Default CSS video', path: 'html-video', skin: 'default', styling: 'css', source: 'hls-1' },
{
Expand Down
83 changes: 68 additions & 15 deletions apps/e2e/tests/sandbox-skin-styling.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';

import { DATA_ATTRS } from '../fixtures/selectors';
import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors';

const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299';

Expand Down Expand Up @@ -43,20 +43,19 @@ for (const { platform, skin, styling } of CASES) {
await expect(root).toBeVisible({ timeout: 15_000 });

const host =
platform === 'html'
? page.locator('video-skin, video-minimal-skin, video-skin-tailwind, video-minimal-skin-tailwind').first()
: root;
platform === 'html' && styling === 'css' ? page.locator('video-skin, video-minimal-skin').first() : root;

await host.evaluate((element) => {
element.style.setProperty('--media-accent-color', '#123456');
element.style.setProperty('--media-accent-text-color', '#abcdef');
element.style.setProperty('--media-border-radius', '18px');
});

const playButton = page.getByRole('button', { name: 'Play' }).first();
const settingsButton = page.getByRole('button', { name: 'Settings' }).first();

await playButton.hover();
await expect(playButton).toHaveCSS('color', 'rgb(171, 205, 239)');
await settingsButton.click();
await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
await expect(settingsButton).toHaveCSS('color', 'rgb(171, 205, 239)');

const styles = await root.evaluate((element) => {
const accent = 'rgb(18, 52, 86)';
Expand Down Expand Up @@ -96,8 +95,7 @@ for (const { platform, skin, styling } of CASES) {

const root = page.getByRole('group', { name: 'Media player' }).first();
const slider = page.getByRole('slider', { name: 'Seek' }).first().locator('..');
// The preview is `aria-hidden`, so `role=img` is the only hook the CSS and Tailwind skins share.
const thumbnail = root.locator('[role="img"]').first();
const thumbnail = root.locator(SELECTORS.thumbnail).first();

await expect(root).toBeVisible({ timeout: 15_000 });
await slider.hover();
Expand All @@ -108,13 +106,17 @@ for (const { platform, skin, styling } of CASES) {
const measure = () =>
thumbnail.evaluate((element) => {
const image = element.shadowRoot?.querySelector('img') ?? element.querySelector('img');

if (!(element instanceof HTMLElement) || !(image instanceof HTMLElement)) {
throw new Error('Expected the thumbnail host and image to be HTML elements.');
}

const rect = element.getBoundingClientRect();
const imageRect = image!.getBoundingClientRect();
const host = element as HTMLElement;
const imageRect = image.getBoundingClientRect();

return {
width: host.offsetWidth,
height: host.offsetHeight,
width: element.offsetWidth,
height: element.offsetHeight,
maxWidth: parseFloat(getComputedStyle(element).maxWidth),
rightGap: rect.right - imageRect.right,
bottomGap: rect.bottom - imageRect.bottom,
Expand All @@ -139,10 +141,10 @@ for (const { platform, skin, styling } of CASES) {
return box.maxWidth - box.width;
})
.toBeLessThanOrEqual(2);
await expect.poll(async () => (await measure()).width).toBeGreaterThan(before.width);

const after = await measure();

expect(after.width).toBeGreaterThan(before.width);
// Aspect ratio survives the resize, so the tile is neither cropped nor letterboxed.
expect(Math.abs(after.width / after.height - before.width / before.height)).toBeLessThan(0.02);
// The sprite still covers the container that clips it.
Expand All @@ -151,6 +153,57 @@ for (const { platform, skin, styling } of CASES) {
});
}

for (const media of ['video', 'audio'] as const) {
for (const { platform, skin, styling } of CASES) {
test(`${platform} ${skin} ${styling} selects the live ${media} skin`, async ({ page }) => {
const query = new URLSearchParams({
styling,
skin,
source: 'hls-live',
autoplay: '0',
muted: '1',
loop: '0',
preload: 'metadata',
});

await page.goto(`${SANDBOX_BASE}/${platform}-hls-${media}/?${query}`, { waitUntil: 'domcontentloaded' });

const root = page.getByRole('group', { name: 'Media player' }).first();
const family = `media-skin--live-${media}`;

await expect(root).toBeVisible({ timeout: 15_000 });
await expect(root).toHaveClass(new RegExp(`(?:^|\\s)${family}(?:\\s|$)`));
await expect(page.getByRole('slider', { name: 'Seek' })).toHaveCount(0);
});
}
}

for (const media of ['video', 'audio'] as const) {
for (const skin of ['default', 'minimal'] as const) {
test(`cdn ${skin} selects the live ${media} skin`, async ({ page }) => {
const query = new URLSearchParams({
preset: `hls-${media}`,
skin,
source: 'hls-live',
autoplay: '0',
muted: '1',
loop: '0',
preload: 'metadata',
});

await page.goto(`${SANDBOX_BASE}/cdn/?${query}`, { waitUntil: 'domcontentloaded' });

const root = page.getByRole('group', { name: 'Media player' }).first();
const family = `media-skin--live-${media}`;

await expect(root).toBeVisible({ timeout: 15_000 });
await expect(page.locator(`live-${media}-player`)).toHaveCount(1);
await expect(root).toHaveClass(new RegExp(`(?:^|\\s)${family}(?:\\s|$)`));
await expect(page.getByRole('slider', { name: 'Seek' })).toHaveCount(0);
});
}
}

for (const { media, skin } of HTML_TAILWIND_ERROR_CASES) {
test(`html ${skin} tailwind ${media} contains the error dialog without changing the closed layout`, async ({
page,
Expand Down Expand Up @@ -240,7 +293,7 @@ for (const { platform, skin, styling } of CASES) {
const muteTooltip = page.locator('[popover="manual"]').filter({ hasText: 'Unmute' }).first();

if (skin === 'minimal') await expect(muteTooltip).toBeVisible();
else await expect(muteTooltip).toHaveCount(0);
else await expect(muteTooltip).toBeHidden();

const volumeThumb = page.getByRole('slider', { name: 'Volume' }).first();

Expand Down
1 change: 1 addition & 0 deletions apps/sandbox/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/*
/app/_generated/
Loading
Loading