diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..10e46598 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,81 @@ +name: E2E Tests + +on: + push: + branches: + - main + - 'release-please--branches--main' + pull_request: + types: [opened, labeled, synchronize] + +jobs: + e2e: + name: E2E (${{ matrix.browser }}) + if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'test:e2e') + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + browser: [chromium, webkit] + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache turbo build + uses: actions/cache@v5 + with: + path: .turbo + key: ${{ runner.os }}-turbo-e2e-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-e2e- + ${{ runner.os }}-turbo- + + - name: Build packages + run: pnpm build:packages + + - name: Build CDN bundles + run: pnpm build:cdn + + - name: Build ejected skins + run: pnpm -F site ejected-skins + + - name: Generate E2E test pages + run: pnpm --dir apps/e2e generate-pages + + - name: Sync ejected skins to E2E app + run: pnpm --dir apps/e2e sync-ejected-skins + + - name: Run E2E tests + run: pnpm --dir apps/e2e exec playwright test --project=vite-${{ matrix.browser }} + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.browser }} + path: apps/e2e/playwright-report/ + retention-days: 14 + + - name: Upload test traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces-${{ matrix.browser }} + path: apps/e2e/test-results/ + retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md index fd331772..4b91f64c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ pnpm build:sandbox # Build specific package pnpm -F build -# Run tests across all packages +# Run unit tests across all packages pnpm test # Run tests for specific package pnpm -F test @@ -95,6 +95,13 @@ pnpm -F test src/path/to/file.test.ts # Run tests matching a glob or filter pnpm -F test src/core +# Run e2e tests (Chromium + WebKit) +pnpm test:e2e +# Run e2e tests (Chromium only, faster) +pnpm test:e2e:vite +# Update visual snapshot baselines +pnpm test:e2e:update + # Lint all workspace packages pnpm lint # Lint and fix a single file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fcc83a1..532cf47c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,6 +129,40 @@ pnpm -F core test # just core package pnpm -F core test:watch # watch core package ``` +#### E2E Tests + +We use [Playwright](https://playwright.dev) for end-to-end testing. The E2E tests live in `apps/e2e/` and run against a Vite-based test app that hosts the player in multiple configurations (HTML, React, ejected skins, CDN bundles). + +Playwright browsers and system dependencies are installed automatically during `pnpm install`. On Linux without sudo, browsers will install but system deps will be skipped with a note. If e2e tests fail due to missing system dependencies, install them manually: + +```sh +sudo pnpm test:e2e:install +``` + +**Running tests:** + +```sh +pnpm test:e2e # Chromium + WebKit (all engines) +pnpm test:e2e:vite # Chromium only (fast feedback) +``` + +**Visual snapshot tests** verify that skin CSS and layout aren't broken. If a snapshot test fails, it means the rendered skin looks different from the baseline. This could mean: + +1. **You intentionally changed the skin** — update the baselines: + ```sh + pnpm test:e2e:update + ``` + Review the updated PNGs in `apps/e2e/tests/visual/` to confirm they look correct, then commit them. + +2. **You didn't intend to change the skin** — the failure caught a real regression. Inspect the diff in the Playwright HTML report: + ```sh + pnpm --dir apps/e2e report + ``` + The report shows expected vs. actual vs. diff images side-by-side. + +> [!TIP] +> Snapshot baselines are checked into git. When you update them, review the PNG diffs in your PR to make sure the visual changes are intentional. + ### 📦 Dependencies To add a dependency to a specific package, you can use [`pnpm` filtering][pnpm-filtering] from the workspace root: diff --git a/apps/e2e/.gitignore b/apps/e2e/.gitignore new file mode 100644 index 00000000..ddea13f3 --- /dev/null +++ b/apps/e2e/.gitignore @@ -0,0 +1,6 @@ +playwright-report/ +test-results/ +blob-report/ +apps/vite/src/_generated/ +apps/vite/src/pages/ +apps/vite/src/index.html diff --git a/apps/e2e/apps/vite/package.json b/apps/e2e/apps/vite/package.json new file mode 100644 index 00000000..3299af6c --- /dev/null +++ b/apps/e2e/apps/vite/package.json @@ -0,0 +1,9 @@ +{ + "name": "@videojs/e2e-vite", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host" + } +} diff --git a/apps/e2e/apps/vite/src/resources.ts b/apps/e2e/apps/vite/src/resources.ts new file mode 100644 index 00000000..42a4a946 --- /dev/null +++ b/apps/e2e/apps/vite/src/resources.ts @@ -0,0 +1,2 @@ +/** Re-export media resources from the shared fixtures. */ +export { MEDIA } from '../../../fixtures/resources'; diff --git a/apps/e2e/apps/vite/vite.config.ts b/apps/e2e/apps/vite/vite.config.ts new file mode 100644 index 00000000..ad55fde6 --- /dev/null +++ b/apps/e2e/apps/vite/vite.config.ts @@ -0,0 +1,61 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +function getPageEntries(): Record { + const entries: Record = {}; + + // Hand-written pages in src/ (ejected, captions, etc.) + const srcDir = resolve(__dirname, 'src'); + for (const entry of readdirSync(srcDir)) { + const file = resolve(srcDir, entry); + if (entry.endsWith('.html') && entry !== 'index.html' && statSync(file).isFile()) { + entries[entry.replace('.html', '')] = file; + } + } + + // Generated pages in src/pages/ + const pagesDir = resolve(__dirname, 'src/pages'); + if (existsSync(pagesDir)) { + for (const entry of readdirSync(pagesDir)) { + const file = resolve(pagesDir, entry); + if (entry.endsWith('.html') && statSync(file).isFile()) { + entries[`pages/${entry.replace('.html', '')}`] = file; + } + } + } + + return entries; +} + +export default defineConfig({ + root: 'src', + appType: 'mpa', + plugins: [react()], + resolve: { + dedupe: ['react', 'react-dom'], + }, + optimizeDeps: { + exclude: [ + '@videojs/core', + '@videojs/html', + '@videojs/icons', + '@videojs/react', + '@videojs/spf', + '@videojs/store', + '@videojs/utils', + ], + }, + build: { + outDir: resolve(__dirname, 'dist'), + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(__dirname, 'src/index.html'), + ...getPageEntries(), + }, + }, + }, +}); diff --git a/apps/e2e/fixtures/media.ts b/apps/e2e/fixtures/media.ts new file mode 100644 index 00000000..1e32b13e --- /dev/null +++ b/apps/e2e/fixtures/media.ts @@ -0,0 +1,141 @@ +/** Playwright project names where a page should be skipped (e.g. no native HLS in Firefox). */ +type BrowserName = 'chromium' | 'webkit' | 'firefox'; + +export interface PageEntry { + name: string; + path: string; + framework: string; + media: string; + resource: string; + skipBrowsers?: BrowserName[]; +} + +// --------------------------------------------------------------------------- +// Packaged skin pages (generated → src/pages/) +// --------------------------------------------------------------------------- + +export const VIDEO_PAGES = [ + { name: 'HTML Video MP4', path: '/pages/html-video-mp4.html', framework: 'html', media: 'video', resource: 'mp4' }, + { + name: 'HTML Video HLS', + path: '/pages/html-video-hls.html', + framework: 'html', + media: 'hls-video', + resource: 'hlsTs', + }, + { + name: 'HTML Simple HLS Video fMP4', + path: '/pages/html-simple-hls-video-fmp4.html', + framework: 'html', + media: 'simple-hls-video', + resource: 'hlsFmp4', + }, + { + name: 'HTML DASH Video', + path: '/pages/html-dash-video.html', + framework: 'html', + media: 'dash-video', + resource: 'dash', + }, + { + name: 'HTML Native HLS Video', + path: '/pages/html-native-hls-video.html', + framework: 'html', + media: 'native-hls-video', + resource: 'hlsTs', + skipBrowsers: ['firefox', 'webkit'], + }, + { + name: 'HTML Mux Video', + path: '/pages/html-mux-video.html', + framework: 'html', + media: 'mux-video', + resource: 'hlsTs', + }, + { + name: 'React Video MP4', + path: '/pages/react-video-mp4.html', + framework: 'react', + media: 'video', + resource: 'mp4', + }, + { + name: 'React Video HLS', + path: '/pages/react-video-hls.html', + framework: 'react', + media: 'hls-video', + resource: 'hlsTs', + }, +] as const satisfies readonly PageEntry[]; + +export const AUDIO_PAGES = [ + { + name: 'HTML Audio MP4', + path: '/pages/html-audio-mp4.html', + framework: 'html', + media: 'audio', + resource: 'mp4', + }, + { + name: 'HTML Mux Audio', + path: '/pages/html-mux-audio.html', + framework: 'html', + media: 'mux-audio', + resource: 'hlsTs', + }, + { + name: 'React Audio MP4', + path: '/pages/react-audio-mp4.html', + framework: 'react', + media: 'audio', + resource: 'mp4', + }, +] as const satisfies readonly PageEntry[]; + +// --------------------------------------------------------------------------- +// Ejected skin pages (generated → src/pages/) +// --------------------------------------------------------------------------- + +export const EJECTED_VIDEO_PAGES = [ + { + name: 'Ejected HTML Video MP4', + path: '/pages/ejected-html-video-mp4.html', + framework: 'html', + media: 'video', + resource: 'mp4', + }, + { + name: 'Ejected React Video MP4', + path: '/pages/ejected-react-video-mp4.html', + framework: 'react', + media: 'video', + resource: 'mp4', + }, +] as const satisfies readonly PageEntry[]; + +// --------------------------------------------------------------------------- +// CDN bundle pages (generated → src/pages/) +// --------------------------------------------------------------------------- + +export const CDN_VIDEO_PAGES = [ + { + name: 'CDN Video MP4', + path: '/pages/cdn-video-mp4.html', + framework: 'html', + media: 'video', + resource: 'mp4', + }, + { + name: 'CDN Video HLS', + path: '/pages/cdn-video-hls.html', + framework: 'html', + media: 'hls-video', + resource: 'hlsTs', + }, +] as const satisfies readonly PageEntry[]; + +// --------------------------------------------------------------------------- +// Combined arrays for parameterized tests +// --------------------------------------------------------------------------- + +export const ALL_VIDEO_PAGES = [...VIDEO_PAGES, ...EJECTED_VIDEO_PAGES, ...CDN_VIDEO_PAGES] as const; diff --git a/apps/e2e/fixtures/resources.ts b/apps/e2e/fixtures/resources.ts new file mode 100644 index 00000000..85fc0f7d --- /dev/null +++ b/apps/e2e/fixtures/resources.ts @@ -0,0 +1,24 @@ +/** Media sources for e2e tests. */ +export const MEDIA = { + mp4: { + url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4', + poster: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg', + storyboard: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.vtt', + }, + /** HLS with TS segments (Big Buck Bunny via Mux). */ + hlsTs: { + url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8', + poster: 'https://image.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA/thumbnail.jpg', + storyboard: 'https://image.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA/storyboard.vtt', + }, + /** HLS with fMP4/CMAF segments (Dancing Dude via Mux). */ + hlsFmp4: { + url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8', + poster: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg', + storyboard: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.vtt', + }, + /** DASH (Big Buck Bunny 30fps via Akamai). */ + dash: { + url: 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd', + }, +} as const; diff --git a/apps/e2e/fixtures/selectors.ts b/apps/e2e/fixtures/selectors.ts new file mode 100644 index 00000000..3a8b7e92 --- /dev/null +++ b/apps/e2e/fixtures/selectors.ts @@ -0,0 +1,80 @@ +/** + * 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. + * + * Both renderers apply the **same data attributes** for state (`data-paused`, + * `data-muted`, etc.), which is what tests assert against. + * + * Each selector uses a CSS `,` (or) to match either renderer. + */ +export const SELECTORS = { + // Player containers + // HTML: , React: wrapper div around VideoSkin + videoPlayer: 'video-player, .media-default-skin--video, .media-minimal-skin--video', + audioPlayer: 'audio-player, .media-default-skin--audio, .media-minimal-skin--audio', + // The visible container with dimensions — used for screenshots + container: '.media-default-skin, .media-minimal-skin', + + // Controls bar + controls: 'media-controls, .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', + captionsButton: 'media-captions-button, .media-button--captions', + playbackRateButton: 'media-playback-rate-button, .media-button--playback-rate', + + // Sliders + // HTML: , 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"]', + sliderThumb: 'media-slider-thumb, .media-slider__thumb', + + // Display elements + // HTML uses attribute `type`, React uses `data-type` + currentTime: 'media-time[type="current"], [data-type="current"].media-time', + duration: 'media-time[type="duration"], [data-type="duration"].media-time', + poster: 'media-poster, img[data-visible]', + bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator', + thumbnail: 'media-slider-thumbnail, .media-preview__thumbnail', + + // Popover & tooltip + tooltip: 'media-tooltip, .media-tooltip', + popover: 'media-popover, .media-popover', + errorDialog: 'media-error-dialog, .media-error', + + // Media element — matches all renderer custom elements and native media + media: 'video, audio, hls-video, simple-hls-video, native-hls-video, dash-video, mux-video, mux-audio', +} as const; + +/** Data attributes used for player state (same across both renderers). */ +export const DATA_ATTRS = { + paused: 'data-paused', + ended: 'data-ended', + started: 'data-started', + muted: 'data-muted', + active: 'data-active', + fullscreen: 'data-fullscreen', + pip: 'data-pip', + visible: 'data-visible', + dragging: 'data-dragging', + pointing: 'data-pointing', + interactive: 'data-interactive', + seeking: 'data-seeking', + disabled: 'data-disabled', + availability: 'data-availability', + volumeLevel: 'data-volume-level', + rate: 'data-rate', + direction: 'data-direction', + loading: 'data-loading', + error: 'data-error', + hidden: 'data-hidden', + open: 'data-open', + userActive: 'data-user-active', +} as const; diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 00000000..a530e1a2 --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,41 @@ +{ + "name": "@videojs/e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "install:browsers": "playwright install", + "install:deps": "playwright install-deps || echo \"Note: Playwright system deps could not be installed. If e2e tests fail, run with sudo: sudo playwright install-deps (or sudo pnpm test:e2e:install from the workspace root)\"", + "postinstall": "node -e \"process.env.CI || process.exit(1)\" || (pnpm run install:browsers && pnpm run install:deps)", + "generate-pages": "tsx scripts/generate-pages.ts", + "sync-ejected-skins": "tsx scripts/sync-ejected-skins.ts", + "e2e": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:ui": "playwright test --ui", + "test:update": "playwright test --update-snapshots", + "test:chromium": "playwright test --project=vite-chromium", + "test:webkit": "playwright test --project=vite-webkit", + "test:vite": "playwright test --project=vite-chromium", + "test:all": "playwright test --project=vite-chromium --project=vite-webkit", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.52.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@videojs/core": "workspace:*", + "@videojs/html": "workspace:*", + "@videojs/icons": "workspace:*", + "@videojs/react": "workspace:*", + "@videojs/skins": "workspace:*", + "@videojs/spf": "workspace:*", + "@videojs/store": "workspace:*", + "@videojs/utils": "workspace:*", + "@vitejs/plugin-react": "^6.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^6.0.2", + "vite": "^8.0.0" + } +} diff --git a/apps/e2e/page-objects/player.ts b/apps/e2e/page-objects/player.ts new file mode 100644 index 00000000..27e53de9 --- /dev/null +++ b/apps/e2e/page-objects/player.ts @@ -0,0 +1,198 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; + +/** + * Page Object Model for the Video.js player. + * + * Uses cross-renderer selectors that work for both: + * - HTML (Web Components): custom element tags like `media-play-button` + * - React: standard elements with CSS classes like `.media-button--play` + * + * Both renderers share the same data attributes for state, so all + * assertions against `data-paused`, `data-muted`, etc. are portable. + * + * Playwright auto-pierces Shadow DOM for HTML custom elements. + */ +export class PlayerPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + // --- Control locators --- + + get playButton(): Locator { + return this.page.locator(SELECTORS.playButton).first(); + } + + get seekForward(): Locator { + return this.page.locator(SELECTORS.seekForward).first(); + } + + get seekBackward(): Locator { + return this.page.locator(SELECTORS.seekBackward).first(); + } + + get timeSlider(): Locator { + return this.page.locator(SELECTORS.timeSlider).first(); + } + + get muteButton(): Locator { + return this.page.locator(SELECTORS.muteButton).first(); + } + + get volumeSlider(): Locator { + return this.page.locator(SELECTORS.volumeSlider).first(); + } + + get fullscreenButton(): Locator { + return this.page.locator(SELECTORS.fullscreenButton).first(); + } + + get pipButton(): Locator { + return this.page.locator(SELECTORS.pipButton).first(); + } + + get captionsButton(): Locator { + return this.page.locator(SELECTORS.captionsButton).first(); + } + + get playbackRateButton(): Locator { + return this.page.locator(SELECTORS.playbackRateButton).first(); + } + + get poster(): Locator { + return this.page.locator(SELECTORS.poster).first(); + } + + get bufferingIndicator(): Locator { + return this.page.locator(SELECTORS.bufferingIndicator).first(); + } + + get controls(): Locator { + return this.page.locator(SELECTORS.controls).first(); + } + + get currentTime(): Locator { + return this.page.locator(SELECTORS.currentTime).first(); + } + + get duration(): Locator { + return this.page.locator(SELECTORS.duration).first(); + } + + get thumbnail(): Locator { + return this.page.locator(SELECTORS.thumbnail).first(); + } + + get popover(): Locator { + return this.page.locator(SELECTORS.popover).first(); + } + + /** Locator for the outermost player wrapper (works for both video and audio). */ + get playerRoot(): Locator { + return this.page.locator(SELECTORS.container).first(); + } + + get videoPlayer(): Locator { + return this.page.locator(SELECTORS.videoPlayer).first(); + } + + get audioPlayer(): Locator { + return this.page.locator(SELECTORS.audioPlayer).first(); + } + + // --- Actions --- + + /** Wait for the player to load media and show controls. */ + async waitForMediaReady({ muted = true }: { muted?: boolean } = {}): Promise { + await this.playButton.waitFor({ state: 'attached', timeout: 20_000 }); + + // Wait for the media element to have at least metadata loaded. + // SPF-based renderers (simple-hls-video) with preload="metadata" need + // time to resolve the manifest before seeks or other interactions work. + await this.page.waitForFunction( + (selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + return actual && actual.readyState >= 1; + }, + SELECTORS.media, + { timeout: 20_000 } + ); + + if (muted) { + // Mute the media element to prevent audio during test runs. + // Chromium supports --mute-audio but WebKit has no browser-level + // equivalent, so we mute the renderer directly for cross-browser silence. + await this.page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + if (actual) actual.muted = true; + }, SELECTORS.media); + } + } + + /** Click play and wait for the paused attribute to be removed. */ + async play(): Promise { + await this.playButton.click(); + await expect(this.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); + } + + /** Click pause and wait for the paused attribute to appear. */ + async pause(): Promise { + // In Firefox headless (especially audio), controls may hide after play + // starts and force-click doesn't reliably dispatch to Shadow DOM buttons. + // Fall back to dispatching click via JS, then verify UI state. + await this.playButton.dispatchEvent('click'); + await expect(this.playButton).toHaveAttribute(DATA_ATTRS.paused, '', { timeout: 5_000 }); + } + + /** Click at a percentage position on the time slider. */ + async seekTo(percent: number): Promise { + // Wait for readyState >= 1 (HAVE_METADATA) and a non-zero duration. + // On WebKit, seeking with no buffered data resolves to currentTime 0 + // instead of the requested position. + await this.page.waitForFunction( + (selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + return actual && actual.readyState >= 1 && actual.duration > 0 && Number.isFinite(actual.duration); + }, + SELECTORS.media, + { timeout: 30_000 } + ); + + const box = await this.timeSlider.boundingBox(); + if (!box) throw new Error('Time slider not visible'); + + const x = box.x + box.width * (percent / 100); + const y = box.y + box.height / 2; + await this.page.mouse.click(x, y); + + // Wait for the seek to complete + await this.page.waitForTimeout(500); + } + + /** Hover over the time slider at a percentage position. */ + async hoverTimeSlider(percent: number): Promise { + const box = await this.timeSlider.boundingBox(); + if (!box) throw new Error('Time slider not visible'); + + const x = box.x + box.width * (percent / 100); + const y = box.y + box.height / 2; + await this.page.mouse.move(x, y); + } + + /** Hover over the player area to trigger user-active state and show controls. */ + async showControls(): Promise { + // Use the play button as anchor — it's always inside the player + const btn = this.playButton; + const box = await btn.boundingBox(); + if (!box) throw new Error('Play button not visible — cannot show controls'); + + await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await this.page.waitForTimeout(200); + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 00000000..2342a9bb --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,92 @@ +import { defineConfig, devices } from '@playwright/test'; + +const CI = !!process.env.CI; + +export default defineConfig({ + testDir: './tests', + // Strip the OS suffix from snapshot paths so one set of baselines works + // on both macOS (local) and Linux (CI). The generous thresholds below + // absorb minor cross-platform rendering differences. + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', + timeout: 60_000, + retries: CI ? 2 : 0, + fullyParallel: true, + + reporter: CI ? [['html', { open: 'never' }], ['github'], ['blob']] : [['html', { open: 'on-failure' }]], + + expect: { + timeout: 10_000, + toHaveScreenshot: { + maxDiffPixelRatio: 0.05, + threshold: 0.3, + animations: 'disabled', + }, + }, + + use: { + trace: CI ? 'on-first-retry' : 'on', + screenshot: 'only-on-failure', + video: CI ? 'on-first-retry' : 'off', + actionTimeout: 10_000, + }, + + projects: [ + // --- Chromium --- + { + name: 'vite-chromium', + use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5180' }, + }, + + // --- WebKit --- + { + name: 'vite-webkit', + use: { ...devices['Desktop Safari'], baseURL: 'http://localhost:5180' }, + }, + + // --- Firefox --- + { + name: 'vite-firefox', + use: { ...devices['Desktop Firefox'], baseURL: 'http://localhost:5180' }, + }, + + // --- Future: Next.js --- + // { + // name: 'next-chromium', + // use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:3100' }, + // }, + + // --- Future: Astro --- + // { + // name: 'astro-chromium', + // use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:4400' }, + // }, + ], + + webServer: [ + { + command: 'npx vite --port 5180', + cwd: './apps/vite', + port: 5180, + reuseExistingServer: !CI, + timeout: 120_000, + }, + + // --- Future: Next.js --- + // { + // command: 'pnpm next dev --port 3100', + // cwd: './apps/next', + // port: 3100, + // reuseExistingServer: !CI, + // timeout: 120_000, + // }, + + // --- Future: Astro --- + // { + // command: 'pnpm astro dev --port 4400', + // cwd: './apps/astro', + // port: 4400, + // reuseExistingServer: !CI, + // timeout: 120_000, + // }, + ], +}); diff --git a/apps/e2e/scripts/generate-pages.ts b/apps/e2e/scripts/generate-pages.ts new file mode 100644 index 00000000..92da6744 --- /dev/null +++ b/apps/e2e/scripts/generate-pages.ts @@ -0,0 +1,537 @@ +/** + * Generates Vite test pages from PageEntry definitions. + * + * Reads the media type configs and page arrays, then writes .ts/.tsx + .html + * files to `apps/vite/src/pages/`. Special pages (ejected skins, captions) are + * hand-written and not generated. + * + * Run: `pnpm --dir apps/e2e generate-pages` + */ + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(__dirname, '../apps/vite/src/pages'); + +// --------------------------------------------------------------------------- +// Media type config — maps media element name to its import + attributes +// --------------------------------------------------------------------------- + +interface MediaTypeConfig { + /** Custom element tag (e.g. 'hls-video', 'mux-video') */ + element: string; + /** Side-effect imports to register the element */ + imports: string[]; + /** HTML attributes on the media element */ + attrs: string; + /** Whether a storyboard track should be included */ + hasStoryboard: boolean; + /** Whether a poster image should be included */ + hasPoster: boolean; + /** Whether this is an audio element */ + isAudio: boolean; +} + +const MEDIA_TYPES: Record = { + video: { + element: 'video', + imports: [], + attrs: 'playsinline crossorigin="anonymous"', + hasStoryboard: true, + hasPoster: true, + isAudio: false, + }, + 'hls-video': { + element: 'hls-video', + imports: ['@videojs/html/media/hls-video'], + attrs: 'playsinline crossorigin="anonymous"', + hasStoryboard: true, + hasPoster: true, + isAudio: false, + }, + 'simple-hls-video': { + element: 'simple-hls-video', + imports: ['@videojs/html/media/simple-hls-video'], + attrs: 'playsinline crossorigin="anonymous" preload="metadata"', + hasStoryboard: true, + hasPoster: true, + isAudio: false, + }, + 'native-hls-video': { + element: 'native-hls-video', + imports: ['@videojs/html/media/native-hls-video'], + attrs: 'playsinline crossorigin="anonymous"', + hasStoryboard: true, + hasPoster: true, + isAudio: false, + }, + 'mux-video': { + element: 'mux-video', + imports: ['@videojs/html/media/mux-video'], + attrs: 'playsinline crossorigin="anonymous"', + hasStoryboard: true, + hasPoster: true, + isAudio: false, + }, + 'dash-video': { + element: 'dash-video', + imports: ['@videojs/html/media/dash-video'], + attrs: 'playsinline', + hasStoryboard: false, + hasPoster: false, + isAudio: false, + }, + audio: { + element: 'audio', + imports: [], + attrs: '', + hasStoryboard: false, + hasPoster: false, + isAudio: true, + }, + 'mux-audio': { + element: 'mux-audio', + imports: ['@videojs/html/media/mux-audio'], + attrs: 'crossorigin="anonymous"', + hasStoryboard: false, + hasPoster: false, + isAudio: true, + }, +}; + +// React component names for media elements +const REACT_MEDIA: Record = { + video: { component: 'Video', importPath: '@videojs/react/video' }, + 'hls-video': { component: 'HlsVideo', importPath: '@videojs/react/media/hls-video' }, + audio: { component: 'Audio', importPath: '@videojs/react/audio' }, +}; + +// CDN import paths (override standard imports) +const CDN_IMPORTS: Record = { + video: ['@videojs/html/cdn/video'], + 'hls-video': ['@videojs/html/cdn/video', '@videojs/html/cdn/media/hls-video'], +}; + +// --------------------------------------------------------------------------- +// Page entry type (matches fixtures/media.ts) +// --------------------------------------------------------------------------- + +interface PageDef { + name: string; + path: string; + framework: 'html' | 'react'; + media: string; + resource: string; + category?: 'cdn' | 'ejected-html' | 'ejected-react' | 'captions'; +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +function htmlShell(title: string, scriptSrc: string): string { + return ` + + + + + ${title} + + +
+ + + +`; +} + +function htmlVideoPage(config: MediaTypeConfig, resource: string, imports: string[]): string { + const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n'); + + const storyboard = config.hasStoryboard + ? `\n ` + : ''; + + const poster = config.hasPoster + ? `\n Video poster` + : ''; + + const attrs = config.attrs ? ` ${config.attrs}` : ''; + + return `${allImports} + +const html = String.raw; + +document.getElementById('root')!.innerHTML = html\` + + + <${config.element} src="\${MEDIA.${resource}.url}"${attrs}>${storyboard} + ${poster} + + +\`; +`; +} + +function htmlAudioPage(config: MediaTypeConfig, resource: string, imports: string[]): string { + const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n'); + const attrs = config.attrs ? ` ${config.attrs}` : ''; + + return `${allImports} + +const html = String.raw; + +document.getElementById('root')!.innerHTML = html\` +
+ + + <${config.element} src="\${MEDIA.${resource}.url}"${attrs}> + + +
+\`; +`; +} + +function reactVideoPage(media: string, resource: string, config: MediaTypeConfig): string { + const reactMedia = REACT_MEDIA[media]; + if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`); + + const isDefaultVideo = media === 'video'; + const mediaImport = isDefaultVideo + ? `import { Video, VideoSkin, videoFeatures } from '@videojs/react/video';` + : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoSkin, videoFeatures } from '@videojs/react/video';`; + + const posterProp = config.hasPoster ? ` poster={MEDIA.${resource}.poster}` : ''; + const storyboardTrack = config.hasStoryboard + ? `\n ` + : ''; + + return `import { createPlayer } from '@videojs/react'; +${mediaImport} +import '@videojs/react/video/skin.css'; +import { createRoot } from 'react-dom/client'; +import { MEDIA } from '../resources'; + +const Player = createPlayer({ features: videoFeatures }); + +function App() { + return ( + + + <${reactMedia.component} src={MEDIA.${resource}.url} playsInline crossOrigin="anonymous">${storyboardTrack} + + + + ); +} + +createRoot(document.getElementById('root')!).render(); +`; +} + +function reactAudioPage(media: string, resource: string): string { + const reactMedia = REACT_MEDIA[media]; + if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`); + + const isDefaultAudio = media === 'audio'; + const mediaImport = isDefaultAudio + ? `import { Audio, AudioSkin, audioFeatures } from '@videojs/react/audio';` + : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { AudioSkin, audioFeatures } from '@videojs/react/audio';`; + + return `import { createPlayer } from '@videojs/react'; +${mediaImport} +import '@videojs/react/audio/skin.css'; +import { createRoot } from 'react-dom/client'; +import { MEDIA } from '../resources'; + +const Player = createPlayer({ features: audioFeatures }); + +function App() { + return ( + + + <${reactMedia.component} src={MEDIA.${resource}.url} /> + + + ); +} + +createRoot(document.getElementById('root')!).render(); +`; +} + +// --------------------------------------------------------------------------- +// Special page templates (captions, ejected skins) +// --------------------------------------------------------------------------- + +function captionsPage(resource: string): string { + const captionVtt = 'WEBVTT\\n\\n00:00:00.000 --> 00:00:30.000\\nThis is a test caption'; + + return `import '@videojs/html/video/player'; +import '@videojs/html/video/skin'; +import { MEDIA } from '../resources'; + +const html = String.raw; + +const captionVtt = encodeURIComponent('${captionVtt}'); + +document.getElementById('root')!.innerHTML = html\` + + + + Video poster + + +\`; +`; +} + +function ejectedHtmlPage(): string { + // Path from pages/ to the site content + const jsonPath = '../../../../../../site/src/content/ejected-skins.json'; + + return `import '@videojs/html/video/ui'; +import ejectedSkins from '${jsonPath}'; + +interface EjectedSkinEntry { + id: string; + html?: string; + css?: string; +} + +const skin = (ejectedSkins as EjectedSkinEntry[]).find((s) => s.id === 'default-video'); + +if (!skin?.html || !skin?.css) { + throw new Error('Ejected skin "default-video" not found. Run \\\`pnpm -F site ejected-skins\\\` first.'); +} + +const style = document.createElement('style'); +style.textContent = skin.css; +document.head.appendChild(style); + +const playerMatch = skin.html.match(/[\\s\\S]*<\\/video-player>/); + +if (!playerMatch) { + throw new Error('Could not find in ejected HTML output.'); +} + +const root = document.getElementById('root')!; +root.innerHTML = \`
\${playerMatch[0]}
\`; +`; +} + +function ejectedReactPage(resource: string): string { + return `import { createRoot } from 'react-dom/client'; +import { VideoPlayer } from '../_generated/ejected-react-video-skin'; +import { MEDIA } from '../resources'; + +function App() { + return ; +} + +createRoot(document.getElementById('root')!).render(); +`; +} + +// --------------------------------------------------------------------------- +// Page definitions (drive generation from here) +// --------------------------------------------------------------------------- + +const PAGES: PageDef[] = [ + // HTML Video + { name: 'HTML Video MP4', path: 'html-video-mp4', framework: 'html', media: 'video', resource: 'mp4' }, + { name: 'HTML Video HLS', path: 'html-video-hls', framework: 'html', media: 'hls-video', resource: 'hlsTs' }, + { + name: 'HTML Simple HLS Video fMP4', + path: 'html-simple-hls-video-fmp4', + framework: 'html', + media: 'simple-hls-video', + resource: 'hlsFmp4', + }, + { name: 'HTML DASH Video', path: 'html-dash-video', framework: 'html', media: 'dash-video', resource: 'dash' }, + { + name: 'HTML Native HLS Video', + path: 'html-native-hls-video', + framework: 'html', + media: 'native-hls-video', + resource: 'hlsTs', + }, + { name: 'HTML Mux Video', path: 'html-mux-video', framework: 'html', media: 'mux-video', resource: 'hlsTs' }, + + // HTML Audio + { name: 'HTML Audio MP4', path: 'html-audio-mp4', framework: 'html', media: 'audio', resource: 'mp4' }, + { name: 'HTML Mux Audio', path: 'html-mux-audio', framework: 'html', media: 'mux-audio', resource: 'hlsTs' }, + + // React Video + { name: 'React Video MP4', path: 'react-video-mp4', framework: 'react', media: 'video', resource: 'mp4' }, + { name: 'React Video HLS', path: 'react-video-hls', framework: 'react', media: 'hls-video', resource: 'hlsTs' }, + + // React Audio + { name: 'React Audio MP4', path: 'react-audio-mp4', framework: 'react', media: 'audio', resource: 'mp4' }, + + // CDN + { name: 'CDN Video MP4', path: 'cdn-video-mp4', framework: 'html', media: 'video', resource: 'mp4', category: 'cdn' }, + { + name: 'CDN Video HLS', + path: 'cdn-video-hls', + framework: 'html', + media: 'hls-video', + resource: 'hlsTs', + category: 'cdn', + }, + + // Captions + { + name: 'HTML Video Captions', + path: 'html-video-captions', + framework: 'html', + media: 'video', + resource: 'mp4', + category: 'captions', + }, + + // Ejected Skins + { + name: 'Ejected HTML Video MP4', + path: 'ejected-html-video-mp4', + framework: 'html', + media: 'video', + resource: 'mp4', + category: 'ejected-html', + }, + { + name: 'Ejected React Video MP4', + path: 'ejected-react-video-mp4', + framework: 'react', + media: 'video', + resource: 'mp4', + category: 'ejected-react', + }, +]; + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +function getImports(page: PageDef, config: MediaTypeConfig): string[] { + if (page.category === 'cdn') { + const cdnImports = CDN_IMPORTS[page.media]; + if (!cdnImports) throw new Error(`No CDN imports for media type: ${page.media}`); + return cdnImports.map((i) => `import '${i}';`); + } + + const playerType = config.isAudio ? 'audio' : 'video'; + const base = [`import '@videojs/html/${playerType}/player';`, `import '@videojs/html/${playerType}/skin';`]; + + for (const imp of config.imports) { + base.push(`import '${imp}';`); + } + + return base; +} + +function generatePage(page: PageDef): { ts: string; html: string; ext: string } { + const config = MEDIA_TYPES[page.media]; + if (!config) throw new Error(`Unknown media type: ${page.media}`); + + const ext = page.framework === 'react' ? 'tsx' : 'ts'; + + let ts: string; + + // Special category pages + if (page.category === 'captions') { + ts = captionsPage(page.resource); + } else if (page.category === 'ejected-html') { + ts = ejectedHtmlPage(); + } else if (page.category === 'ejected-react') { + ts = ejectedReactPage(page.resource); + } else if (page.framework === 'react') { + ts = config.isAudio ? reactAudioPage(page.media, page.resource) : reactVideoPage(page.media, page.resource, config); + } else { + const imports = getImports(page, config); + ts = config.isAudio ? htmlAudioPage(config, page.resource, imports) : htmlVideoPage(config, page.resource, imports); + } + + const html = htmlShell(page.name, `${page.path}.${ext}`); + + return { ts, html, ext }; +} + +function generateIndexHtml(pages: PageDef[]): string { + const noCategory = (p: PageDef) => !p.category; + const htmlVideo = pages.filter((p) => p.framework === 'html' && !MEDIA_TYPES[p.media]?.isAudio && noCategory(p)); + const htmlAudio = pages.filter((p) => p.framework === 'html' && MEDIA_TYPES[p.media]?.isAudio && noCategory(p)); + const reactVideo = pages.filter((p) => p.framework === 'react' && !MEDIA_TYPES[p.media]?.isAudio && noCategory(p)); + const reactAudio = pages.filter((p) => p.framework === 'react' && MEDIA_TYPES[p.media]?.isAudio && noCategory(p)); + const cdn = pages.filter((p) => p.category === 'cdn'); + const ejected = pages.filter((p) => p.category?.startsWith('ejected')); + const captions = pages.filter((p) => p.category === 'captions'); + + function list(entries: PageDef[]): string { + return entries.map((p) => `
  • ${p.name}
  • `).join('\n'); + } + + return ` + + + + + Video.js E2E Test Pages + + +

    Video.js E2E Test Pages

    + + + +`; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +console.log('[generate-pages] Generating Vite test pages...'); + +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); + +let count = 0; + +for (const page of PAGES) { + const { ts, html, ext } = generatePage(page); + + writeFileSync(resolve(OUT_DIR, `${page.path}.${ext}`), ts); + writeFileSync(resolve(OUT_DIR, `${page.path}.html`), html); + count++; +} + +// Generate index.html in src/ (not pages/) +const srcDir = resolve(OUT_DIR, '..'); +writeFileSync(resolve(srcDir, 'index.html'), generateIndexHtml(PAGES)); + +console.log(`[generate-pages] Generated ${count} pages + index.html`); diff --git a/apps/e2e/scripts/sync-ejected-skins.ts b/apps/e2e/scripts/sync-ejected-skins.ts new file mode 100644 index 00000000..66e2ea33 --- /dev/null +++ b/apps/e2e/scripts/sync-ejected-skins.ts @@ -0,0 +1,37 @@ +/** + * Syncs the generated ejected skin source from the docs site build + * into the e2e test app so the ejected React test uses real output. + * + * Run after `pnpm -F site ejected-skins` and before e2e tests. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const EJECTED_SKINS_JSON = resolve(import.meta.dirname, '../../../site/src/content/ejected-skins.json'); +const OUT_DIR = resolve(import.meta.dirname, '../apps/vite/src/_generated'); + +interface EjectedSkinEntry { + id: string; + platform: string; + tsx?: string; + css?: string; +} + +const skins: EjectedSkinEntry[] = JSON.parse(readFileSync(EJECTED_SKINS_JSON, 'utf-8')); + +const reactVideo = skins.find((s) => s.id === 'default-video-react'); + +if (!reactVideo?.tsx) { + throw new Error('Ejected skin "default-video-react" not found. Run `pnpm -F site ejected-skins` first.'); +} + +mkdirSync(OUT_DIR, { recursive: true }); + +writeFileSync(resolve(OUT_DIR, 'ejected-react-video-skin.tsx'), reactVideo.tsx); + +if (reactVideo.css) { + writeFileSync(resolve(OUT_DIR, 'player.css'), reactVideo.css); +} + +console.log('Synced ejected React video skin to apps/e2e/apps/vite/src/_generated/'); diff --git a/apps/e2e/tests/audio-controls.spec.ts b/apps/e2e/tests/audio-controls.spec.ts new file mode 100644 index 00000000..1392d229 --- /dev/null +++ b/apps/e2e/tests/audio-controls.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test'; +import { AUDIO_PAGES, type PageEntry } from '../fixtures/media'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +for (const { name, path, skipBrowsers } of AUDIO_PAGES as readonly PageEntry[]) { + test.describe(`Audio 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(); + }); + + // --- 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, ''); + }); + + // --- Seek Buttons --- + + test('seek forward button is present', async () => { + await expect(player.seekForward).toBeAttached(); + }); + + test('seek backward button is present', async () => { + await expect(player.seekBackward).toBeAttached(); + }); + + // --- 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); + }); + + // --- Mute / Volume --- + + test('mute button toggles mute', async () => { + // Media starts muted (see PlayerPage.waitForMediaReady) + 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, ''); + }); + + test('mute button shows volume level', async () => { + await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel); + }); + + // --- Playback Rate --- + + test('playback rate button cycles rates', async () => { + const rateBtn = player.playbackRateButton; + const initialRate = await rateBtn.getAttribute(DATA_ATTRS.rate); + + await rateBtn.click(); + await player.page.waitForTimeout(200); + + const newRate = await rateBtn.getAttribute(DATA_ATTRS.rate); + expect(newRate).not.toBe(initialRate); + }); + + // Audio skin does NOT have: fullscreen, PiP, captions, poster, storyboard + }); +} diff --git a/apps/e2e/tests/captions.spec.ts b/apps/e2e/tests/captions.spec.ts new file mode 100644 index 00000000..fe548158 --- /dev/null +++ b/apps/e2e/tests/captions.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +test.describe('Captions', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('captions button shows unavailable without subtitle tracks', async ({ page }) => { + const captionsBtn = page.locator(SELECTORS.captionsButton).first(); + await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'unavailable'); + }); + + test('captions button becomes available when subtitle track is added', async ({ page }) => { + const captionsBtn = page.locator(SELECTORS.captionsButton).first(); + + // Initially unavailable + await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'unavailable'); + + // Add a subtitle track + 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); + }); + + // Button should switch to available + await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'available', { + timeout: 5_000, + }); + }); +}); diff --git a/apps/e2e/tests/error-dialog.spec.ts b/apps/e2e/tests/error-dialog.spec.ts new file mode 100644 index 00000000..8f288181 --- /dev/null +++ b/apps/e2e/tests/error-dialog.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +test.describe('Error Dialog', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('shows error dialog on media load failure', async ({ page }) => { + const errorDialog = page.locator(SELECTORS.errorDialog).first(); + + // Set an invalid source to trigger a media error + await page.evaluate(() => { + const video = document.querySelector('video') as HTMLVideoElement; + if (video) video.src = 'https://example.com/does-not-exist.mp4'; + }); + + // Error dialog should appear with data-open + await expect(errorDialog).toHaveAttribute(DATA_ATTRS.open, '', { timeout: 15_000 }); + }); + + test('error dialog can be dismissed', async ({ page }) => { + const errorDialog = page.locator(SELECTORS.errorDialog).first(); + + // Trigger error + await page.evaluate(() => { + const video = document.querySelector('video') as HTMLVideoElement; + if (video) video.src = 'https://example.com/does-not-exist.mp4'; + }); + + await expect(errorDialog).toHaveAttribute(DATA_ATTRS.open, '', { timeout: 15_000 }); + + // Click the close/OK button + const closeButton = page.locator('media-alert-dialog-close, .media-button--primary').first(); + await closeButton.click(); + + // Dialog should close + await expect(errorDialog).not.toHaveAttribute(DATA_ATTRS.open, { timeout: 5_000 }); + }); +}); diff --git a/apps/e2e/tests/gestures.spec.ts b/apps/e2e/tests/gestures.spec.ts new file mode 100644 index 00000000..edab7cff --- /dev/null +++ b/apps/e2e/tests/gestures.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +/** + * Gesture tests — validates the media-gesture components respond to + * pointer events on the player container (not individual buttons). + * + * The player's gesture system: + * - tap (mouse, center): togglePaused + * - tap (touch): toggleControls + * - doubletap (left): seek backward 10s + * - doubletap (center): toggleFullscreen + * - doubletap (right): seek forward 10s + */ + +// Helper to get center coordinates of an element +async function getCenter(player: PlayerPage) { + const box = await player.playerRoot.boundingBox(); + if (!box) throw new Error('Player not visible'); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +// --- Mouse gestures (pointer="mouse") --- + +test.describe('Mouse Gestures', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('click center of container toggles play/pause', async ({ page }) => { + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + + // Click the center of the player (not a button) — should toggle play + const { x, y } = await getCenter(player); + await page.mouse.click(x, y); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); + + // Click again — should pause + await page.mouse.click(x, y); + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, '', { timeout: 5_000 }); + }); + + test('click on button does not trigger container gesture', async () => { + // Clicking the play button should only fire the button action, not the + // container gesture. If both fired, play would toggle twice (no-op). + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + await player.playButton.click(); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); + }); +}); + +// --- Touch gestures (pointer="touch") --- + +test.describe('Touch Gestures', () => { + test.use({ hasTouch: true, viewport: { width: 375, height: 667 } }); + + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('tap container toggles controls visibility', async ({ page }) => { + // Tap the center of the player container with touch + const { x, y } = await getCenter(player); + await page.touchscreen.tap(x, y); + await page.waitForTimeout(300); + + // Controls should respond to the tap gesture + await expect(player.controls).toBeAttached(); + }); + + test('double-tap right side seeks forward', async ({ page }) => { + // Start playback first so seek has buffered data + await player.play(); + await page.waitForTimeout(500); + + // Get a point in the right third of the player + const box = await player.playerRoot.boundingBox(); + if (!box) throw new Error('Player not visible'); + const rightX = box.x + box.width * 0.85; + const centerY = box.y + box.height / 2; + + // Double-tap right side — should seek forward 10s + await page.touchscreen.tap(rightX, centerY); + await page.waitForTimeout(50); + await page.touchscreen.tap(rightX, centerY); + + // Verify the player registered a seek (data-started should be set) + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.started, ''); + }); + + test('double-tap left side seeks backward', async ({ page }) => { + // Play and seek forward first so there's room to seek back + await player.play(); + await page.waitForTimeout(1_000); + + // Get a point in the left third of the player + const box = await player.playerRoot.boundingBox(); + if (!box) throw new Error('Player not visible'); + const leftX = box.x + box.width * 0.15; + const centerY = box.y + box.height / 2; + + // Double-tap left side — should seek backward 10s + await page.touchscreen.tap(leftX, centerY); + await page.waitForTimeout(50); + await page.touchscreen.tap(leftX, centerY); + + // Player should still be started (seek doesn't stop playback) + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.started, ''); + }); +}); diff --git a/apps/e2e/tests/keyboard.spec.ts b/apps/e2e/tests/keyboard.spec.ts new file mode 100644 index 00000000..47dbdde8 --- /dev/null +++ b/apps/e2e/tests/keyboard.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +test.describe('Keyboard Navigation', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('Space key toggles play/pause on play button', async ({ page }) => { + await player.playButton.focus(); + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + + await page.keyboard.press('Space'); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused); + + await page.keyboard.press('Space'); + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + }); + + test('Enter key toggles play/pause on play button', async ({ page }) => { + await player.playButton.focus(); + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + + await page.keyboard.press('Enter'); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused); + + await page.keyboard.press('Enter'); + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + }); + + test('Tab navigates between controls', async ({ page }) => { + await player.playButton.focus(); + await expect(player.playButton).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(player.playButton).not.toBeFocused(); + }); + + test('Arrow keys adjust time slider', async ({ page }) => { + // Seek somewhere first so arrow keys have room to adjust + await player.seekTo(50); + + // The slider thumb (role="slider") receives keyboard focus, not the slider root + const sliderThumb = page.locator(SELECTORS.sliderThumb).first(); + await sliderThumb.focus(); + + // Press ArrowRight — should trigger a seek (data-started is already set from seekTo) + const timeBefore = await player.currentTime.textContent(); + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(500); + + // Time should have changed after arrow key + const timeAfter = await player.currentTime.textContent(); + expect(timeAfter).not.toBe(timeBefore); + }); + + test('Space key toggles mute on mute button', async ({ page }) => { + await player.muteButton.focus(); + // Media starts muted (see PlayerPage.waitForMediaReady) + await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.muted, ''); + + await page.keyboard.press('Space'); + await expect(player.muteButton).not.toHaveAttribute(DATA_ATTRS.muted); + + await page.keyboard.press('Space'); + await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.muted, ''); + }); +}); diff --git a/apps/e2e/tests/video-controls.spec.ts b/apps/e2e/tests/video-controls.spec.ts new file mode 100644 index 00000000..f1b18efe --- /dev/null +++ b/apps/e2e/tests/video-controls.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from '@playwright/test'; +import { ALL_VIDEO_PAGES, type PageEntry } 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[]) { + 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.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); + await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.availability); + 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, ''); + }); + + // --- Seek --- + + test('seek forward advances playback', async () => { + test.skip(media === 'simple-hls-video' || 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 }) => { + 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 --- + + test('playback rate button cycles rates', async () => { + const rateBtn = player.playbackRateButton; + const initialRate = await rateBtn.getAttribute(DATA_ATTRS.rate); + + await rateBtn.click(); + await player.page.waitForTimeout(200); + + const newRate = await rateBtn.getAttribute(DATA_ATTRS.rate); + expect(newRate).not.toBe(initialRate); + }); + + // --- Poster --- + + test('poster hides after playback starts', async ({ page }) => { + await player.play(); + + const posterVisible = await page.evaluate((attr) => { + const htmlPoster = document.querySelector('media-poster'); + if (htmlPoster) return htmlPoster.hasAttribute(attr); + + const reactPoster = document.querySelector(`img[${attr}]`); + return !!reactPoster; + }, DATA_ATTRS.visible); + + expect(posterVisible).toBe(false); + }); + }); +} diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts b/apps/e2e/tests/visual/audio-skin.spec.ts new file mode 100644 index 00000000..30048652 --- /dev/null +++ b/apps/e2e/tests/visual/audio-skin.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { PlayerPage } from '../../page-objects/player'; + +/** + * Visual snapshot tests for the audio skin. + * + * Verifies the audio skin's CSS and layout aren't broken. + */ + +const VISUAL_PAGES = [ + { name: 'HTML', path: '/pages/html-audio-mp4.html' }, + { name: 'React', path: '/pages/react-audio-mp4.html' }, +]; + +for (const { name, path } of VISUAL_PAGES) { + test.describe(`Visual — Audio Skin (${name})`, () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto(path); + await player.waitForMediaReady(); + }); + + test('default paused state', async ({ page }) => { + await page.waitForTimeout(300); + + await expect(player.playerRoot).toHaveScreenshot(`audio-${name.toLowerCase()}-default.png`); + }); + }); +} diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png new file mode 100644 index 00000000..6d719708 Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-firefox.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-firefox.png new file mode 100644 index 00000000..562948cc Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png new file mode 100644 index 00000000..c2e2eba7 Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png new file mode 100644 index 00000000..6d719708 Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-firefox.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-firefox.png new file mode 100644 index 00000000..562948cc Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png new file mode 100644 index 00000000..0dc7e485 Binary files /dev/null and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts b/apps/e2e/tests/visual/video-skin.spec.ts new file mode 100644 index 00000000..5f11b67c --- /dev/null +++ b/apps/e2e/tests/visual/video-skin.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; +import { DATA_ATTRS, SELECTORS } from '../../fixtures/selectors'; +import { PlayerPage } from '../../page-objects/player'; + +/** + * Visual snapshot tests for the video skin. + * + * These verify the skin's CSS and layout aren't broken — not UX interactions. + * Strategy: + * - Screenshot the skin container in its initial paused state + * - Generous pixel thresholds absorb cross-platform rendering differences + * - Animations disabled globally (configured in playwright.config.ts) + */ + +const VISUAL_PAGES = [ + { name: 'HTML', path: '/pages/html-video-mp4.html' }, + { name: 'React', path: '/pages/react-video-mp4.html' }, + { name: 'Ejected-HTML', path: '/pages/ejected-html-video-mp4.html' }, + { name: 'Ejected-React', path: '/pages/ejected-react-video-mp4.html' }, + { name: 'CDN', path: '/pages/cdn-video-mp4.html' }, +]; + +for (const { name, path } of VISUAL_PAGES) { + test.describe(`Visual — Video Skin (${name})`, () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto(path); + await player.waitForMediaReady(); + }); + + test('default paused state', async ({ page }) => { + await player.showControls(); + await page.waitForTimeout(300); + + await expect(player.playerRoot).toHaveScreenshot(`video-${name.toLowerCase()}-default.png`); + }); + + test('storyboard thumbnail on hover', async ({ page }) => { + 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.playerRoot).toHaveScreenshot(`video-${name.toLowerCase()}-storyboard.png`); + }); + }); +} + +// --- Captions snapshot (dedicated page with subtitle track baked in) --- + +test.describe('Visual — Captions', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-captions.html'); + await player.waitForMediaReady(); + }); + + test('captions enabled', async ({ page, browserName }) => { + // WebKit doesn't render data:text/vtt subtitle tracks in headless mode + test.skip(browserName === 'webkit', 'WebKit headless does not render data:text/vtt captions'); + // Captions button should be available (subtitle track is in the HTML) + const captionsBtn = page.locator(SELECTORS.captionsButton).first(); + await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'available', { + timeout: 5_000, + }); + + // Enable captions + await captionsBtn.click(); + await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.active, ''); + + // Play briefly so the caption cue at 0:00 activates, then pause + await player.play(); + await page.waitForTimeout(500); + await player.pause(); + await player.showControls(); + + await expect(player.playerRoot).toHaveScreenshot('captions-enabled.png'); + }); +}); + +// --- Mobile viewport snapshot (375×667) --- + +test.describe('Visual — Mobile Layout', () => { + test.use({ viewport: { width: 375, height: 667 } }); + + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/html-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('mobile layout', async ({ page }) => { + await player.showControls(); + await page.waitForTimeout(300); + + await expect(player.playerRoot).toHaveScreenshot('mobile-default.png'); + }); +}); diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/captions-enabled-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/captions-enabled-vite-chromium.png new file mode 100644 index 00000000..2e1e4aa0 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/captions-enabled-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-chromium.png new file mode 100644 index 00000000..bf1eab75 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-webkit.png new file mode 100644 index 00000000..9e1bff1a Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/mobile-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-chromium.png new file mode 100644 index 00000000..f3e9c8c2 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-firefox.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-firefox.png new file mode 100644 index 00000000..0458b2f9 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-webkit.png new file mode 100644 index 00000000..e2817bda Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-chromium.png new file mode 100644 index 00000000..61733e06 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-webkit.png new file mode 100644 index 00000000..f3ec0aa5 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-cdn-storyboard-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-chromium.png new file mode 100644 index 00000000..cedeb58c Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-firefox.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-firefox.png new file mode 100644 index 00000000..8711fa28 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-webkit.png new file mode 100644 index 00000000..a85f4ad7 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-chromium.png new file mode 100644 index 00000000..a8c5a0e9 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-webkit.png new file mode 100644 index 00000000..9ead2aa0 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-html-storyboard-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-chromium.png new file mode 100644 index 00000000..0c972a95 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-firefox.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-firefox.png new file mode 100644 index 00000000..0458b2f9 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-webkit.png new file mode 100644 index 00000000..856b18c0 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-chromium.png new file mode 100644 index 00000000..bb65c026 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-webkit.png new file mode 100644 index 00000000..0aa3c0c2 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-ejected-react-storyboard-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-chromium.png new file mode 100644 index 00000000..0c972a95 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-firefox.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-firefox.png new file mode 100644 index 00000000..45ef3625 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-webkit.png new file mode 100644 index 00000000..da237ca4 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-chromium.png new file mode 100644 index 00000000..b11f09e7 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-webkit.png new file mode 100644 index 00000000..acf43986 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-html-storyboard-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-chromium.png new file mode 100644 index 00000000..dcc565a7 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-firefox.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-firefox.png new file mode 100644 index 00000000..460cbf7b Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-firefox.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-webkit.png new file mode 100644 index 00000000..0d0d7299 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-chromium.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-chromium.png new file mode 100644 index 00000000..d92632e1 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-webkit.png b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-webkit.png new file mode 100644 index 00000000..56114016 Binary files /dev/null and b/apps/e2e/tests/visual/video-skin.spec.ts-snapshots/video-react-storyboard-vite-webkit.png differ diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 00000000..725177d2 --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["fixtures/**/*.ts", "page-objects/**/*.ts", "tests/**/*.ts", "apps/**/*.ts", "apps/**/*.tsx"] +} diff --git a/package.json b/package.json index 794968d1..f0936b6f 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,11 @@ "lint:fix:file": "biome check --write", "link:aliases": "node build/scripts/link-aliases.mjs", "size": "node .github/scripts/bundle-size.js | node .github/scripts/bundle-size-report.js", - "test": "turbo run test", - "test:packages": "turbo run test --filter='./packages/*'", + "test": "turbo run test --filter='./packages/*'", + "test:e2e": "pnpm --dir apps/e2e test:all", + "test:e2e:vite": "pnpm --dir apps/e2e test:vite", + "test:e2e:update": "pnpm --dir apps/e2e test:update", + "test:e2e:install": "pnpm --dir apps/e2e run install:browsers && pnpm --dir apps/e2e run install:deps", "format:astro": "prettier --write 'site/src/**/*.astro'", "typecheck": "tsc --build", "changelog": "git-cliff --config cliff.toml --output CHANGELOG.md", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fba1a19..0ae47bff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,57 @@ importers: specifier: ^6.0.2 version: 6.0.2 + apps/e2e: + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.59.1 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@videojs/core': + specifier: workspace:* + version: link:../../packages/core + '@videojs/html': + specifier: workspace:* + version: link:../../packages/html + '@videojs/icons': + specifier: workspace:* + version: link:../../packages/icons + '@videojs/react': + specifier: workspace:* + version: link:../../packages/react + '@videojs/skins': + specifier: workspace:* + version: link:../../packages/skins + '@videojs/spf': + specifier: workspace:* + version: link:../../packages/spf + '@videojs/store': + specifier: workspace:* + version: link:../../packages/store + '@videojs/utils': + specifier: workspace:* + version: link:../../packages/utils + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.0 + version: 8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + apps/sandbox: dependencies: '@videojs/core': @@ -2373,6 +2424,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -10058,6 +10114,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': diff --git a/turbo.json b/turbo.json index d2d50115..e873b3e3 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,10 @@ }, "lint": { "outputs": [] + }, + "e2e": { + "dependsOn": ["^build"], + "cache": false } } }