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`
+
+
+
+
+
+
+
+
+`;
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`
+
+
+
+
+
+
+`;
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
+