diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..32f0b52de --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,76 @@ +name: E2E Tests + +on: + push: + branches: + - main + pull_request: + branches-ignore: + - 'rfc/**' + +jobs: + e2e: + name: E2E (${{ matrix.browser }}) + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # PRs: Chromium only for fast feedback (~2 min). + # Push to main: Chromium + WebKit for full coverage. + browser: ${{ github.event_name == 'push' && fromJson('["chromium","webkit"]') || fromJson('["chromium"]') }} + + 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: 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/apps/e2e/.gitignore b/apps/e2e/.gitignore new file mode 100644 index 000000000..a0f157aee --- /dev/null +++ b/apps/e2e/.gitignore @@ -0,0 +1,3 @@ +playwright-report/ +test-results/ +blob-report/ diff --git a/apps/e2e/apps/vite/package.json b/apps/e2e/apps/vite/package.json new file mode 100644 index 000000000..3299af6cd --- /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/cdn-video-hls.html b/apps/e2e/apps/vite/src/cdn-video-hls.html new file mode 100644 index 000000000..ccdedbf03 --- /dev/null +++ b/apps/e2e/apps/vite/src/cdn-video-hls.html @@ -0,0 +1,12 @@ + + + + + + CDN Video HLS + + +
+ + + diff --git a/apps/e2e/apps/vite/src/cdn-video-hls.ts b/apps/e2e/apps/vite/src/cdn-video-hls.ts new file mode 100644 index 000000000..70eee6d2f --- /dev/null +++ b/apps/e2e/apps/vite/src/cdn-video-hls.ts @@ -0,0 +1,23 @@ +/** + * CDN bundle test page — HLS. + * + * Imports the CDN video bundle + HLS media plugin. Uses a different HLS + * source (Elephants Dream) for media variety. + */ + +import '@videojs/html/cdn/video'; +import '@videojs/html/cdn/media/hls-video'; +import { MEDIA } from './shared'; + +const html = String.raw; + +document.getElementById('root')!.innerHTML = html` + + + + + + Video poster + + +`; diff --git a/apps/e2e/apps/vite/src/cdn-video-mp4.html b/apps/e2e/apps/vite/src/cdn-video-mp4.html new file mode 100644 index 000000000..5c84e3eec --- /dev/null +++ b/apps/e2e/apps/vite/src/cdn-video-mp4.html @@ -0,0 +1,12 @@ + + + + + + CDN Video MP4 + + +
+ + + diff --git a/apps/e2e/apps/vite/src/cdn-video-mp4.ts b/apps/e2e/apps/vite/src/cdn-video-mp4.ts new file mode 100644 index 000000000..74f60ff56 --- /dev/null +++ b/apps/e2e/apps/vite/src/cdn-video-mp4.ts @@ -0,0 +1,23 @@ +/** + * CDN bundle test page — MP4. + * + * Imports from `@videojs/html/cdn/video` which is the self-contained CDN + * bundle. This tests a completely different code path from the workspace + * package imports used in the other test pages. + */ + +import '@videojs/html/cdn/video'; +import { MEDIA } from './shared'; + +const html = String.raw; + +document.getElementById('root')!.innerHTML = html` + + + + Video poster + + +`; diff --git a/apps/e2e/apps/vite/src/ejected-html-video-mp4.html b/apps/e2e/apps/vite/src/ejected-html-video-mp4.html new file mode 100644 index 000000000..5157786a4 --- /dev/null +++ b/apps/e2e/apps/vite/src/ejected-html-video-mp4.html @@ -0,0 +1,12 @@ + + + + + + Ejected HTML Video MP4 + + +
+ + + diff --git a/apps/e2e/apps/vite/src/ejected-html-video-mp4.ts b/apps/e2e/apps/vite/src/ejected-html-video-mp4.ts new file mode 100644 index 000000000..eae7d078e --- /dev/null +++ b/apps/e2e/apps/vite/src/ejected-html-video-mp4.ts @@ -0,0 +1,45 @@ +/** + * Ejected HTML skin test page. + * + * Reads the actual output of `build-ejected-skins.ts` and renders it. + * This validates that the generated ejected HTML works end-to-end. + */ + +// Register all UI custom elements (without the skin wrapper) +import '@videojs/html/video/ui'; + +// Import the generated ejected skins JSON +import ejectedSkins from '../../../../../site/src/content/ejected-skins.json'; + +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.'); +} + +// Inject the ejected CSS into the document +const style = document.createElement('style'); +style.textContent = skin.css; +document.head.appendChild(style); + +// The ejected HTML contains: +// +// +// ... +// +// Strip the + + diff --git a/apps/e2e/apps/vite/src/ejected-react-video-mp4.tsx b/apps/e2e/apps/vite/src/ejected-react-video-mp4.tsx new file mode 100644 index 000000000..dcf3c597a --- /dev/null +++ b/apps/e2e/apps/vite/src/ejected-react-video-mp4.tsx @@ -0,0 +1,250 @@ +/** + * Ejected React skin test page. + * + * Composes individual React UI components manually — exactly what a user + * does after ejecting the VideoSkin component. This mirrors the composition + * from `packages/react/src/presets/video/skin.tsx`. + */ + +import { + CaptionsOffIcon, + CaptionsOnIcon, + FullscreenEnterIcon, + FullscreenExitIcon, + PauseIcon, + PipEnterIcon, + PipExitIcon, + PlayIcon, + RestartIcon, + SeekIcon, + SpinnerIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@videojs/icons/react'; +import { + BufferingIndicator, + CaptionsButton, + Container, + Controls, + createPlayer, + ErrorDialog, + FullscreenButton, + MuteButton, + PiPButton, + PlayButton, + PlaybackRateButton, + Popover, + Poster, + SeekButton, + Slider, + Time, + TimeSlider, + Tooltip, + usePlayer, + VolumeSlider, +} from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; +import '@videojs/react/video/skin.css'; +import { type ComponentProps, forwardRef, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MEDIA } from './shared'; + +const SEEK_TIME = 10; + +const Player = createPlayer({ features: videoFeatures }); + +const Button = forwardRef>(function Button({ className, ...props }, ref) { + const base = 'media-button media-button--subtle media-button--icon'; + return