diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f36708956a..2bab389de2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,9 +13,83 @@ concurrency: cancel-in-progress: true jobs: + # Pull requests run the browser suites when they touch code the suites render; the `test:e2e` label forces them. + changes: + name: Changed areas + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + player: ${{ steps.filter.outputs.player }} + skins: ${{ steps.filter.outputs.skins }} + steps: + - name: Detect e2e-relevant changes + id: filter + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + run: | + files=$(gh api "repos/$GH_REPO/pulls/$NUMBER/files" --paginate --jq '.[].filename') + flag() { + if grep -Eq "$2" <<<"$files"; then echo "$1=true" >> "$GITHUB_OUTPUT"; else echo "$1=false" >> "$GITHUB_OUTPUT"; fi + } + # The player suite renders the built packages; skin parity compiles the authored skins in the sandbox. + flag player '^(apps/e2e/|apps/sandbox/|packages/|\.github/workflows/e2e\.yml$|pnpm-lock\.yaml$)' + flag skins '^(apps/e2e/(shared|suites/skin-parity)/|apps/sandbox/|packages/(skins|vjsc|html|react)/|\.github/workflows/e2e\.yml$|pnpm-lock\.yaml$)' + + # Every browser job replays this run's Vite Task cache, so the packages, CDN bundles, generated skins, and sandbox + # setup compile once per run instead of once per job. + build: + name: Build for browser suites + needs: changes + if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.player == 'true' || needs.changes.outputs.skins == 'true' || contains(github.event.pull_request.labels.*.name, 'test:e2e') || contains(github.event.pull_request.labels.*.name, 'record:snapshots')) }} + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 15 + + 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: Restore Vite Task cache + id: vite-task-cache + uses: actions/cache/restore@v6 + with: + path: node_modules/.vite/task-cache + key: vite-task-${{ runner.os }}-${{ runner.arch }}-e2e-${{ github.sha }}-${{ github.run_attempt }} + restore-keys: | + vite-task-${{ runner.os }}-${{ runner.arch }}-e2e- + vite-task-${{ runner.os }}-${{ runner.arch }}-packages- + + - name: Build packages and CDN bundles + run: pnpm build:packages && pnpm build:cdn + + - name: Prepare the e2e pages, generated skins, and sandbox + run: pnpm exec vp run '@videojs/e2e#prepare:player' && pnpm exec vp run '@videojs/skins#generate' && pnpm exec vp run '@videojs/sandbox#setup' + + - name: Save Vite Task cache + uses: actions/cache/save@v6 + with: + path: node_modules/.vite/task-cache + key: ${{ steps.vite-task-cache.outputs.cache-primary-key }} + e2e: name: E2E (${{ matrix.browser }}) - if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'test:e2e') + needs: [changes, build] + if: ${{ !cancelled() && needs.build.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.player == 'true' || contains(github.event.pull_request.labels.*.name, 'test:e2e')) }} runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.59.1-noble @@ -23,7 +97,8 @@ jobs: strategy: fail-fast: false matrix: - browser: [chromium, webkit] + # WebKit doubles the wall time, so pull requests run Chromium only and main covers both. + browser: ${{ fromJSON(github.event_name == 'push' && '["chromium", "webkit"]' || '["chromium"]') }} steps: - name: Checkout code @@ -45,26 +120,17 @@ jobs: uses: actions/cache/restore@v6 with: path: node_modules/.vite/task-cache - key: vite-task-${{ runner.os }}-${{ runner.arch }}-restore-only-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + key: vite-task-${{ runner.os }}-${{ runner.arch }}-e2e-${{ github.sha }}-${{ github.run_attempt }} restore-keys: | + vite-task-${{ runner.os }}-${{ runner.arch }}-e2e- vite-task-${{ runner.os }}-${{ runner.arch }}-packages- - - name: Build packages - run: pnpm build:packages - - - name: Build CDN bundles - run: pnpm build:cdn - - - name: Generate E2E test pages - run: pnpm --dir apps/e2e generate-pages + - name: Replay the build + run: pnpm build:packages && pnpm build:cdn && pnpm exec vp run '@videojs/e2e#prepare:player' - name: Run E2E tests run: pnpm --dir apps/e2e exec playwright test --config suites/player/playwright.config.ts --project=vite-${{ matrix.browser }} - - name: Run sandbox E2E tests - if: matrix.browser == 'chromium' - run: pnpm exec vp run '@videojs/e2e#test:sandbox' - - name: Upload test report if: always() uses: actions/upload-artifact@v4 @@ -81,9 +147,63 @@ jobs: path: apps/e2e/test-results/ retention-days: 7 + sandbox: + name: Sandbox + needs: [changes, build] + if: ${{ !cancelled() && needs.build.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.player == 'true' || 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 + + 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: Restore Vite Task cache + uses: actions/cache/restore@v6 + with: + path: node_modules/.vite/task-cache + key: vite-task-${{ runner.os }}-${{ runner.arch }}-e2e-${{ github.sha }}-${{ github.run_attempt }} + restore-keys: | + vite-task-${{ runner.os }}-${{ runner.arch }}-e2e- + vite-task-${{ runner.os }}-${{ runner.arch }}-packages- + + - name: Run sandbox E2E tests + run: pnpm exec vp run '@videojs/e2e#test:sandbox' + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-sandbox + path: apps/e2e/playwright-report/sandbox/ + retention-days: 14 + + - name: Upload test traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces-sandbox + path: apps/e2e/test-results/sandbox/ + retention-days: 7 + skin-parity: name: Skin parity (${{ matrix.preset }}) - if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'test:e2e') + needs: [changes, build] + if: ${{ !cancelled() && needs.build.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.skins == 'true' || contains(github.event.pull_request.labels.*.name, 'test:e2e')) }} runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.59.1-noble @@ -113,8 +233,9 @@ jobs: uses: actions/cache/restore@v6 with: path: node_modules/.vite/task-cache - key: vite-task-${{ runner.os }}-${{ runner.arch }}-restore-only-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + key: vite-task-${{ runner.os }}-${{ runner.arch }}-e2e-${{ github.sha }}-${{ github.run_attempt }} restore-keys: | + vite-task-${{ runner.os }}-${{ runner.arch }}-e2e- vite-task-${{ runner.os }}-${{ runner.arch }}-packages- - name: Run skin parity E2E tests @@ -138,6 +259,58 @@ jobs: path: apps/e2e/test-results/skin-parity/ retention-days: 7 + # Baselines only match when recorded in the image the suites run in. Label a pull request `record:snapshots` to + # record the player visual specs here; download the artifact, copy the `*-snapshots` directories into place, and + # commit them. + snapshots: + name: Record snapshots + needs: [changes, build] + if: ${{ !cancelled() && needs.build.result == 'success' && contains(github.event.pull_request.labels.*.name, 'record:snapshots') }} + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 20 + + 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: Restore Vite Task cache + uses: actions/cache/restore@v6 + with: + path: node_modules/.vite/task-cache + key: vite-task-${{ runner.os }}-${{ runner.arch }}-e2e-${{ github.sha }}-${{ github.run_attempt }} + restore-keys: | + vite-task-${{ runner.os }}-${{ runner.arch }}-e2e- + vite-task-${{ runner.os }}-${{ runner.arch }}-packages- + + - name: Replay the build + run: pnpm build:packages && pnpm build:cdn && pnpm exec vp run '@videojs/e2e#prepare:player' + + - name: Record player visual snapshots + working-directory: apps/e2e + run: pnpm exec playwright test --config suites/player/playwright.config.ts visual --update-snapshots --project=vite-chromium --project=vite-webkit + + - name: Upload snapshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-snapshots + path: apps/e2e/suites/player/tests/**/*-snapshots/ + retention-days: 7 + registry: name: Registry consumers runs-on: ubuntu-latest diff --git a/apps/e2e/package.json b/apps/e2e/package.json index d28469da53..ab1cf3c9b3 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -25,6 +25,7 @@ "@playwright/test": "^1.52.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@videojs/cdn": "workspace:*", "@videojs/core": "workspace:*", "@videojs/html": "workspace:*", "@videojs/icons": "workspace:*", diff --git a/apps/e2e/scripts/generate-pages.ts b/apps/e2e/scripts/generate-pages.ts index 0aec180c76..5d463939ea 100644 --- a/apps/e2e/scripts/generate-pages.ts +++ b/apps/e2e/scripts/generate-pages.ts @@ -153,6 +153,20 @@ interface PageDef { media: string; resource: string; category?: 'cdn' | 'captions' | 'background' | 'background-preset' | 'source-html' | 'source-react'; + /** The packaged skin theme; the default skin unless set. */ + skin?: 'minimal'; +} + +type SkinTheme = NonNullable | 'default'; + +/** The custom element a packaged HTML skin registers. */ +function htmlSkinTag(media: 'video' | 'audio', skin: SkinTheme): string { + return skin === 'minimal' ? `${media}-minimal-skin` : `${media}-skin`; +} + +/** The entry a packaged skin ships under for both frameworks, as `video/skin` or `video/minimal-skin`. */ +function skinEntry(media: 'video' | 'audio', skin: SkinTheme): string { + return skin === 'minimal' ? `${media}/minimal-skin` : `${media}/skin`; } // --------------------------------------------------------------------------- @@ -179,8 +193,9 @@ function resourceHasPoster(resource: string): boolean { return resource === 'mp4' || resource === 'hlsTs' || resource === 'hlsFmp4'; } -function htmlVideoPage(config: MediaTypeConfig, resource: string, imports: string[]): string { +function htmlVideoPage(config: MediaTypeConfig, resource: string, imports: string[], skin: SkinTheme): string { const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n'); + const tag = htmlSkinTag('video', skin); const storyboard = config.hasStoryboard ? `\n ` @@ -199,18 +214,19 @@ const html = String.raw; document.getElementById('root')!.innerHTML = html\` - + <${tag} style="max-width: 800px; aspect-ratio: 16/9"> <${config.element} src="\${MEDIA.${resource}.url}"${attrs}>${storyboard} ${poster} - + \`; `; } -function htmlAudioPage(config: MediaTypeConfig, resource: string, imports: string[]): string { +function htmlAudioPage(config: MediaTypeConfig, resource: string, imports: string[], skin: SkinTheme): string { const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n'); const attrs = config.attrs ? ` ${config.attrs}` : ''; + const tag = htmlSkinTag('audio', skin); return `${allImports} @@ -219,23 +235,26 @@ const html = String.raw; document.getElementById('root')!.innerHTML = html\`
- + <${tag}> <${config.element} src="\${MEDIA.${resource}.url}"${attrs}> - +
\`; `; } -function reactVideoPage(media: string, resource: string, config: MediaTypeConfig): string { +function reactVideoPage(media: string, resource: string, config: MediaTypeConfig, skin: SkinTheme): string { const reactMedia = REACT_MEDIA[media]; if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`); const isDefaultVideo = media === 'video'; + const Skin = skin === 'minimal' ? 'MinimalVideoSkin' : 'VideoSkin'; + const skinImport = + skin === 'minimal' ? `\nimport { MinimalVideoSkin } from '@videojs/react/video/minimal-skin';` : ''; const mediaImport = isDefaultVideo - ? `import { Video, VideoPlayer, VideoSkin } from '@videojs/react/video';` - : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoPlayer, VideoSkin } from '@videojs/react/video';`; + ? `import { Video, VideoPlayer${skin === 'minimal' ? '' : ', VideoSkin'} } from '@videojs/react/video';${skinImport}` + : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoPlayer${skin === 'minimal' ? '' : ', VideoSkin'} } from '@videojs/react/video';${skinImport}`; const posterProp = config.hasPoster && resourceHasPoster(resource) ? ` poster={MEDIA.${resource}.poster}` : ''; const storyboardTrack = config.hasStoryboard @@ -243,17 +262,17 @@ function reactVideoPage(media: string, resource: string, config: MediaTypeConfig : ''; return `${mediaImport} -import '@videojs/react/video/skin.css'; +import '@videojs/react/${skinEntry('video', skin)}.css'; import { createRoot } from 'react-dom/client'; import { MEDIA } from '../resources'; function App() { return ( - + <${Skin} style={{ maxWidth: 800, aspectRatio: '16/9' }}> <${reactMedia.component} src={MEDIA.${resource}.url} playsInline crossOrigin="anonymous">${storyboardTrack} - + ); } @@ -262,26 +281,29 @@ createRoot(document.getElementById('root')!).render(); `; } -function reactAudioPage(media: string, resource: string): string { +function reactAudioPage(media: string, resource: string, skin: SkinTheme): string { const reactMedia = REACT_MEDIA[media]; if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`); const isDefaultAudio = media === 'audio'; + const Skin = skin === 'minimal' ? 'MinimalAudioSkin' : 'AudioSkin'; + const skinImport = + skin === 'minimal' ? `\nimport { MinimalAudioSkin } from '@videojs/react/audio/minimal-skin';` : ''; const mediaImport = isDefaultAudio - ? `import { Audio, AudioPlayer, AudioSkin } from '@videojs/react/audio';` - : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { AudioPlayer, AudioSkin } from '@videojs/react/audio';`; + ? `import { Audio, AudioPlayer${skin === 'minimal' ? '' : ', AudioSkin'} } from '@videojs/react/audio';${skinImport}` + : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { AudioPlayer${skin === 'minimal' ? '' : ', AudioSkin'} } from '@videojs/react/audio';${skinImport}`; return `${mediaImport} -import '@videojs/react/audio/skin.css'; +import '@videojs/react/${skinEntry('audio', skin)}.css'; import { createRoot } from 'react-dom/client'; import { MEDIA } from '../resources'; function App() { return ( - + <${Skin} style={{ maxWidth: 600, margin: '0 auto' }}> <${reactMedia.component} src={MEDIA.${resource}.url} /> - + ); } @@ -585,6 +607,40 @@ const PAGES: PageDef[] = [ // React Audio { name: 'React Audio MP4', path: 'react-audio-mp4', framework: 'react', media: 'audio', resource: 'mp4' }, + // Minimal skins, for the layout snapshots + { + name: 'HTML Video Minimal MP4', + path: 'html-video-minimal-mp4', + framework: 'html', + media: 'video', + resource: 'mp4', + skin: 'minimal', + }, + { + name: 'React Video Minimal MP4', + path: 'react-video-minimal-mp4', + framework: 'react', + media: 'video', + resource: 'mp4', + skin: 'minimal', + }, + { + name: 'HTML Audio Minimal MP4', + path: 'html-audio-minimal-mp4', + framework: 'html', + media: 'audio', + resource: 'mp4', + skin: 'minimal', + }, + { + name: 'React Audio Minimal MP4', + path: 'react-audio-minimal-mp4', + framework: 'react', + media: 'audio', + resource: 'mp4', + skin: 'minimal', + }, + // CDN { name: 'CDN Video MP4', path: 'cdn-video-mp4', framework: 'html', media: 'video', resource: 'mp4', category: 'cdn' }, { @@ -638,7 +694,10 @@ function getImports(page: PageDef, config: MediaTypeConfig): string[] { } const playerType = config.isAudio ? 'audio' : 'video'; - const base = [`import '@videojs/html/${playerType}/player';`, `import '@videojs/html/${playerType}/skin';`]; + const base = [ + `import '@videojs/html/${playerType}/player';`, + `import '@videojs/html/${skinEntry(playerType, page.skin ?? 'default')}';`, + ]; for (const imp of config.imports) { base.push(`import '${imp}';`); @@ -670,11 +729,16 @@ function generatePage(page: PageDef): { ts: string; html: string; ext: string } } else if (page.category === 'source-react') { ts = sourceReactPage(page.resource); } else if (page.framework === 'react') { - ts = config.isAudio ? reactAudioPage(page.media, page.resource) : reactVideoPage(page.media, page.resource, config); + ts = config.isAudio + ? reactAudioPage(page.media, page.resource, page.skin ?? 'default') + : reactVideoPage(page.media, page.resource, config, page.skin ?? 'default'); } else { const imports = getImports(page, config); + const skin = page.skin ?? 'default'; - ts = config.isAudio ? htmlAudioPage(config, page.resource, imports) : htmlVideoPage(config, page.resource, imports); + ts = config.isAudio + ? htmlAudioPage(config, page.resource, imports, skin) + : htmlVideoPage(config, page.resource, imports, skin); } const html = htmlShell(page.name, `${page.path}.${ext}`); diff --git a/apps/e2e/shared/playwright.ts b/apps/e2e/shared/playwright.ts index bb4a2d81bf..44693bd5ca 100644 --- a/apps/e2e/shared/playwright.ts +++ b/apps/e2e/shared/playwright.ts @@ -3,6 +3,8 @@ import { resolve } from 'node:path'; import type { PlaywrightTestConfig } from '@playwright/test'; const CI = Boolean(process.env.CI); +/** Pull requests retry once: a second retry mostly re-records video and traces for the same flake. */ +const RETRIES = process.env.GITHUB_EVENT_NAME === 'pull_request' ? 1 : 2; const e2eDir = resolve(import.meta.dirname, '..'); /** Apply the shared reporting, retry, trace, and screenshot policy to one E2E suite. */ @@ -11,7 +13,7 @@ export function suiteConfig(name: string): PlaywrightTestConfig { snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', outputDir: resolve(e2eDir, 'test-results', name), timeout: 60_000, - retries: CI ? 2 : 0, + retries: CI ? RETRIES : 0, fullyParallel: true, reporter: CI ? [['html', { open: 'never', outputFolder: resolve(e2eDir, 'playwright-report', name) }], ['github'], ['blob']] diff --git a/apps/e2e/suites/player/playwright.config.ts b/apps/e2e/suites/player/playwright.config.ts index 60ad2a969b..70f16da389 100644 --- a/apps/e2e/suites/player/playwright.config.ts +++ b/apps/e2e/suites/player/playwright.config.ts @@ -4,6 +4,11 @@ import { defineConfig, devices } from '@playwright/test'; import { suiteConfig } from '../../shared/playwright.ts'; +/** + * Product behavior and visual regression of the built packages, served by the e2e app on Chromium and WebKit. A test + * here asserts one expected outcome. Comparisons between skin outputs belong in `skin-parity`; the sandbox shell's own + * behavior belongs in `sandbox`. + */ export default defineConfig({ ...suiteConfig('player'), testDir: resolve(import.meta.dirname, 'tests'), diff --git a/apps/e2e/suites/player/tests/skin-overrides.spec.ts b/apps/e2e/suites/player/tests/skin-overrides.spec.ts new file mode 100644 index 0000000000..f494658253 --- /dev/null +++ b/apps/e2e/suites/player/tests/skin-overrides.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +import { PlayerPage } from '../../../shared/page-objects/player'; + +/** + * Consumers restyle the packaged skins from plain, unlayered stylesheets. The skin rules live in a cascade layer, so a + * consumer selector of the same specificity has to win without `!important`. + */ +test.describe('Skin overrides — React', () => { + test('semantic CSS stays easy to override from unlayered consumer styles', async ({ page }) => { + const player = new PlayerPage(page); + + await page.goto('/pages/react-video-mp4.html'); + await player.waitForMediaReady(); + await page.addStyleTag({ + content: '.media-play-button { width: 44px; height: 44px; background: rgb(18 52 86); }', + }); + + // WebKit also exposes the AirPlay button, whose name contains "Play". + const play = player.playerRoot.getByRole('button', { name: 'Play', exact: true }); + + await expect(play).toHaveCSS('width', '44px'); + await expect(play).toHaveCSS('height', '44px'); + await expect(play).toHaveCSS('background-color', 'rgb(18, 52, 86)'); + }); +}); diff --git a/apps/e2e/suites/player/tests/video-controls.spec.ts b/apps/e2e/suites/player/tests/video-controls.spec.ts index 20f939819f..b079dc4a77 100644 --- a/apps/e2e/suites/player/tests/video-controls.spec.ts +++ b/apps/e2e/suites/player/tests/video-controls.spec.ts @@ -275,9 +275,13 @@ for (const { name, path } of UI_VIDEO_PAGES) { const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; if (!actual) return; + // The store clears `waiting` once playback advances past the stall, so hold the clock where the stall began. + const stalledAt = actual.currentTime; + Object.defineProperties(actual, { paused: { configurable: true, get: () => false }, readyState: { configurable: true, get: () => HTMLMediaElement.HAVE_CURRENT_DATA }, + currentTime: { configurable: true, get: () => stalledAt, set: () => {} }, }); actual.dispatchEvent(new Event('waiting')); }, SELECTORS.media); diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts new file mode 100644 index 0000000000..640dfa2d43 --- /dev/null +++ b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; + +import { PlayerPage } from '../../../../shared/page-objects/player'; + +/** + * Layout snapshots of every packaged skin at the widths where its layout changes. These hold the shipped CSS skins to + * their baselines over time; the skin-parity suite compares stylings and sources of one skin in the same page instead. + */ + +const VIDEO_WIDTHS = [320, 800] as const; +const AUDIO_WIDTHS = [384, 672] as const; + +const LAYOUTS = [ + { framework: 'html', skin: 'default', media: 'video', path: '/pages/html-video-mp4.html', sizes: 'video-skin' }, + { + framework: 'html', + skin: 'minimal', + media: 'video', + path: '/pages/html-video-minimal-mp4.html', + sizes: 'video-minimal-skin', + }, + { framework: 'react', skin: 'default', media: 'video', path: '/pages/react-video-mp4.html', sizes: '.media-skin' }, + { + framework: 'react', + skin: 'minimal', + media: 'video', + path: '/pages/react-video-minimal-mp4.html', + sizes: '.media-skin', + }, + { framework: 'html', skin: 'default', media: 'audio', path: '/pages/html-audio-mp4.html', sizes: '#root > div' }, + { + framework: 'html', + skin: 'minimal', + media: 'audio', + path: '/pages/html-audio-minimal-mp4.html', + sizes: '#root > div', + }, + { framework: 'react', skin: 'default', media: 'audio', path: '/pages/react-audio-mp4.html', sizes: '.media-skin' }, + { + framework: 'react', + skin: 'minimal', + media: 'audio', + path: '/pages/react-audio-minimal-mp4.html', + sizes: '.media-skin', + }, +] as const; + +for (const { framework, skin, media, path, sizes } of LAYOUTS) { + test.describe(`Visual — ${skin} ${media} skin layout (${framework})`, () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto(path); + await player.waitForMediaReady(); + }); + + for (const width of media === 'video' ? VIDEO_WIDTHS : AUDIO_WIDTHS) { + test(`paused at ${width}px`, async ({ page }) => { + // The page caps the player at its widest layout; pin the element that carries that cap to one width. + await page + .locator(sizes) + .first() + .evaluate((element: HTMLElement, value) => { + element.style.maxWidth = 'none'; + element.style.width = `${value}px`; + }, width); + + if (media === 'video') await player.showControls(); + + await expect(player.playerRoot).toHaveScreenshot(`layout-${framework}-${skin}-${media}-${width}.png`); + }); + } + }); +} diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-chromium.png new file mode 100644 index 0000000000..fd7991cf48 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-webkit.png new file mode 100644 index 0000000000..ec70b1cb67 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-384-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-chromium.png new file mode 100644 index 0000000000..3fa80692d3 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-webkit.png new file mode 100644 index 0000000000..99aa8f1ff9 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-audio-672-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-chromium.png new file mode 100644 index 0000000000..6b34eb32da Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-webkit.png new file mode 100644 index 0000000000..e9291b9278 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-320-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-chromium.png new file mode 100644 index 0000000000..a6e71242fc Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-webkit.png new file mode 100644 index 0000000000..41c1174e7d Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-default-video-800-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-chromium.png new file mode 100644 index 0000000000..2408c640a1 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-webkit.png new file mode 100644 index 0000000000..1751058bfd Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-384-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-chromium.png new file mode 100644 index 0000000000..200a50507c Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-webkit.png new file mode 100644 index 0000000000..f94a055677 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-audio-672-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-chromium.png new file mode 100644 index 0000000000..f5c2dd9ec7 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-webkit.png new file mode 100644 index 0000000000..c1ef407ef3 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-320-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-chromium.png new file mode 100644 index 0000000000..e58d8d58ee Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-webkit.png new file mode 100644 index 0000000000..3c0e912e9e Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-html-minimal-video-800-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-chromium.png new file mode 100644 index 0000000000..705c6ad501 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-webkit.png new file mode 100644 index 0000000000..6d62051629 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-384-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-chromium.png new file mode 100644 index 0000000000..94394ac210 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-webkit.png new file mode 100644 index 0000000000..987266d37a Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-audio-672-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-chromium.png new file mode 100644 index 0000000000..a0aa2401f0 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-webkit.png new file mode 100644 index 0000000000..856c236686 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-320-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-chromium.png new file mode 100644 index 0000000000..7fae562206 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-webkit.png new file mode 100644 index 0000000000..573959aa20 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-default-video-800-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-chromium.png new file mode 100644 index 0000000000..3b30e10b61 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-webkit.png new file mode 100644 index 0000000000..8b38abc073 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-384-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-chromium.png new file mode 100644 index 0000000000..73b69568fe Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-webkit.png new file mode 100644 index 0000000000..e4aca5ac00 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-audio-672-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-chromium.png new file mode 100644 index 0000000000..32c877a2c2 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-webkit.png new file mode 100644 index 0000000000..30cef53547 Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-320-vite-webkit.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-chromium.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-chromium.png new file mode 100644 index 0000000000..66c9b6a4ab Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-chromium.png differ diff --git a/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-webkit.png b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-webkit.png new file mode 100644 index 0000000000..41f822348b Binary files /dev/null and b/apps/e2e/suites/player/tests/visual/skin-layouts.spec.ts-snapshots/layout-react-minimal-video-800-vite-webkit.png differ diff --git a/apps/e2e/suites/sandbox/playwright.config.ts b/apps/e2e/suites/sandbox/playwright.config.ts index c224e621d8..e360e378c2 100644 --- a/apps/e2e/suites/sandbox/playwright.config.ts +++ b/apps/e2e/suites/sandbox/playwright.config.ts @@ -4,6 +4,7 @@ import { defineConfig, devices } from '@playwright/test'; import { suiteConfig } from '../../shared/playwright.ts'; +/** The sandbox shell itself: its controls, compare and mirror modes, report, and localization. */ export default defineConfig({ ...suiteConfig('sandbox'), testDir: resolve(import.meta.dirname, 'tests'), diff --git a/apps/e2e/suites/skin-parity/playwright.config.ts b/apps/e2e/suites/skin-parity/playwright.config.ts index 1718b4dc12..c0a9fce03c 100644 --- a/apps/e2e/suites/skin-parity/playwright.config.ts +++ b/apps/e2e/suites/skin-parity/playwright.config.ts @@ -7,6 +7,11 @@ import { suiteConfig } from '../../shared/playwright.ts'; /** CI shards the suite per preset; one value restricts the run to that preset's spec. */ const preset = process.env.VJSC_SKIN_PRESET; +/** + * Generator correctness: one authored skin rendered through CSS and through Tailwind, and through the authored and the + * packaged source, has to agree. A test belongs here only when it compares two panels of the same skin; a skin behavior + * with one expected outcome belongs in the `player` suite, where it also runs on WebKit. + */ export default defineConfig({ ...suiteConfig('skin-parity'), testDir: resolve(import.meta.dirname, 'tests'), diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-384-vjsc-chromium.png deleted file mode 100644 index f1ad3ca1ca..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-672-vjsc-chromium.png deleted file mode 100644 index 523d9d77be..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-default-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-384-vjsc-chromium.png deleted file mode 100644 index 8731cd1d50..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-672-vjsc-chromium.png deleted file mode 100644 index efd1ca703a..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/html-minimal-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-384-vjsc-chromium.png deleted file mode 100644 index 0d57a6afa2..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-672-vjsc-chromium.png deleted file mode 100644 index 198184a652..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-default-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-384-vjsc-chromium.png deleted file mode 100644 index 69c66f5028..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-672-vjsc-chromium.png deleted file mode 100644 index efd1ca703a..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts-snapshots/react-minimal-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-384-vjsc-chromium.png deleted file mode 100644 index 73b88dca00..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-672-vjsc-chromium.png deleted file mode 100644 index a16be7bcfa..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-default-live-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-384-vjsc-chromium.png deleted file mode 100644 index ea5acda5a7..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-672-vjsc-chromium.png deleted file mode 100644 index 4d87b9e66c..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/html-minimal-live-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-384-vjsc-chromium.png deleted file mode 100644 index b74ffb09d3..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-672-vjsc-chromium.png deleted file mode 100644 index e53d8432d2..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-default-live-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-384-vjsc-chromium.png deleted file mode 100644 index 5a0ca85bd5..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-672-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-672-vjsc-chromium.png deleted file mode 100644 index b4a45585f2..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-audio-skin-styling.spec.ts-snapshots/react-minimal-live-audio-672-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts index f82e4886fc..73f0bd8494 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts @@ -15,11 +15,11 @@ import { openSourceComparison, popupAncestor, popupContract, + presetVolume, type SkinCase, skinCases, type SkinComparison, type SkinPanel, - snapshotReference, type SourceComparison, surfaceContract, waitForStableText, @@ -124,7 +124,7 @@ for (const variant of CASES) { const name = `${variant.framework}-${variant.skin}-fullscreen.png`; const { css, tailwind } = await openVariants(page, variant, 800); const cssContract = await enterFullscreen(css.root); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); await exitFullscreen(page); @@ -158,11 +158,13 @@ for (const variant of CASES) { const contracts = []; for (const panel of comparison.panels) { - contracts.push({ - captions: await feedbackContract(panel, 'c', '[data-status="captions-on"], [data-status="captions-off"]'), - playback: await feedbackContract(panel, 'k', '[data-status="play"], [data-status="pause"]'), - volume: await feedbackContract(panel, 'ArrowUp', '[data-level]:not([role])'), - }); + const captions = await feedbackContract(panel, 'c', '[data-status="captions-on"], [data-status="captions-off"]'); + const playback = await feedbackContract(panel, 'k', '[data-status="play"], [data-status="pause"]'); + + await presetVolume(panel); + const volume = await feedbackContract(panel, 'ArrowUp', '[data-level]:not([role])'); + + contracts.push({ captions, playback, volume }); } expect(contracts[1]!).toEqual(contracts[0]!); diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-384-vjsc-chromium.png deleted file mode 100644 index 09b1339741..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-680-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-680-vjsc-chromium.png deleted file mode 100644 index 7d0fcbd8b8..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-680-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index 2e5c547649..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-default-live-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-384-vjsc-chromium.png deleted file mode 100644 index c751835222..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-680-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-680-vjsc-chromium.png deleted file mode 100644 index d96fb30557..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-680-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index ffe06c835e..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/html-minimal-live-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-384-vjsc-chromium.png deleted file mode 100644 index 0520958ae7..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-680-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-680-vjsc-chromium.png deleted file mode 100644 index fee3fb491c..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-680-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index bbe2d8b1b6..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-default-live-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-384-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-384-vjsc-chromium.png deleted file mode 100644 index 767496d3ba..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-384-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-680-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-680-vjsc-chromium.png deleted file mode 100644 index 489f8e09b0..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-680-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index 9e853e4bca..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts-snapshots/react-minimal-live-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts b/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts index f8012e14a4..68e5a85d2b 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts @@ -248,19 +248,6 @@ export async function captureRendering( return { name, image: await target.screenshot({ ...CAPTURE_OPTIONS, mask: [...mask] }) }; } -/** Assert one rendering against its stored baseline and return that same paint as the reference for its sibling. */ -export async function snapshotReference( - target: Locator, - name: string, - options: RenderingOptions = {} -): Promise { - await alignToPixelGrid(target); - await settleFonts(target); - await expect(target).toHaveScreenshot(name, { mask: [...(options.mask ?? [])] }); - - return captureRendering(target, name, options); -} - /** Assert that one rendering matches a reference captured from the same page, pixel for pixel within tolerance. */ export async function expectSameRendering( testInfo: TestInfo, @@ -284,7 +271,7 @@ export interface RenderingParityOptions { readonly mask?: (panel: SkinPanel) => readonly Locator[]; } -/** Hold the CSS panel to its baseline, then hold the Tailwind panel to that same CSS paint. */ +/** Capture the CSS panel, then hold the Tailwind panel to that same paint. */ export async function expectRenderingParity( testInfo: TestInfo, comparison: SkinComparison, @@ -293,7 +280,7 @@ export async function expectRenderingParity( ) { if (before) for (const panel of comparison.panels) await before(panel); - const reference = await snapshotReference(target(comparison.css), name, { mask: mask?.(comparison.css) }); + const reference = await captureRendering(target(comparison.css), name, { mask: mask?.(comparison.css) }); await expectSameRendering(testInfo, reference, target(comparison.tailwind), { mask: mask?.(comparison.tailwind) }); } @@ -453,6 +440,28 @@ export async function controlsVisibilityContract(controls: Locator) { }); } +/** + * Hold the media at one mid-range volume before a volume shortcut. The indicator shakes and reports the level it lands + * on, so two panels only agree when the key moves them both away from the same level and neither reaches a boundary. + */ +export async function presetVolume({ section }: SkinPanel, level = 0.5) { + const video = section.locator('video').first(); + + // The shortcut steps from the store's volume, which follows the element through `volumechange`. The store's listener + // registered first, so a listener added now runs after it, and its microtask lands after the store has flushed. + await video.evaluate( + (element: HTMLVideoElement, value) => + new Promise((resolve) => { + if (element.volume === value && !element.muted) return resolve(); + + element.addEventListener('volumechange', () => queueMicrotask(resolve), { once: true }); + element.muted = false; + element.volume = value; + }), + level + ); +} + /** Triggers keyboard feedback and verifies its rendered-presence lifecycle. */ export async function feedbackContract({ frame, root }: SkinPanel, key: string, selector: string) { const page = root.page(); diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts index 73d66430c9..1311595af4 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts @@ -10,12 +10,13 @@ import { freezeSliderState, openComparison, openSourceComparison, + presetVolume, type SkinCase, skinCases, type SkinComparison, type SkinPanel, - snapshotReference, type SourceComparison, + settleFonts, } from './vjsc-skin-parity'; const CASES = skinCases('video'); @@ -25,25 +26,6 @@ const BUFFERING_INDICATOR_SELECTOR = '.media-buffering-indicator, media-buffering-indicator, [class~="peer/buffering"], [class~="hidden"][class~="place-content-center"]'; const CONTROLS_SELECTOR = '.video-controls'; -test('the sandbox width control resizes VJSC skins', async ({ page }) => { - const { css } = await openVariants(page, REACT_DEFAULT, 384); - const range = page.getByRole('slider', { name: 'Width' }); - - await range.fill('512'); - - await expect - .poll(() => - css.root.evaluate((element) => { - const tree = element.getRootNode(); - const sizingTarget = tree instanceof ShadowRoot ? tree.host : element; - - return Math.round(sizingTarget.getBoundingClientRect().width); - }) - ) - .toBe(512); - await expect.poll(() => new URL(page.url()).searchParams.get('width')).toBe('512'); -}); - for (const variant of CASES) { test(`${variant.framework} ${variant.skin} keeps CSS and Tailwind layout in sync`, async ({ page }) => { for (const width of WIDTHS) { @@ -91,7 +73,7 @@ for (const variant of CASES) { const cssButton = await focusPlayButton(css.root); const cssFocused = await buttonStateContract(cssButton); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindButton = await focusPlayButton(tailwind.root); expect(await buttonStateContract(tailwindButton)).toEqual(cssFocused); @@ -110,7 +92,7 @@ for (const variant of CASES) { const name = `${variant.framework}-${variant.skin}-seek-focus.png`; const { css, tailwind } = await openVariants(page, variant, 800); const cssContract = await seekFocusContract(css.root); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindContract = await seekFocusContract(tailwind.root); expect(tailwindContract).toEqual(cssContract); @@ -138,7 +120,7 @@ for (const variant of CASES) { await enableCaptions(css); await page.mouse.move(0, 0); const cssContract = await hideControls(css.root); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); await enableCaptions(tailwind); await page.mouse.move(0, 0); @@ -152,7 +134,7 @@ for (const variant of CASES) { const name = `${variant.framework}-${variant.skin}-buffering.png`; const { css, tailwind } = await openVariants(page, variant, 800); const cssContract = await showBuffering(css.root); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindContract = await showBuffering(tailwind.root); expect(tailwindContract).toEqual(cssContract); @@ -170,7 +152,7 @@ for (const variant of CASES) { expectPopupMotion(cssMotion); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindPopup = await openVolumePopover(tailwind); const tailwindContract = await popupSurfaceContract(tailwind.root, tailwindPopup); const tailwindSliderContract = await volumeSliderContract(tailwindPopup); @@ -196,7 +178,7 @@ for (const variant of CASES) { expectPopupMotion(cssMotion); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindTooltip = await openTooltip(tailwind.root, 'Play'); const tailwindContract = await tooltipSurfaceContract(tailwind.root, tailwindTooltip); const tailwindMotion = await popupMotionContract(tailwindTooltip); @@ -243,7 +225,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800); const cssMenu = await openSettingsMenu(css.root); const cssContract = await popupContract(css.root, cssMenu); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindMenu = await openSettingsMenu(tailwind.root); const tailwindContract = await popupContract(tailwind.root, tailwindMenu); @@ -256,7 +238,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800); const cssSubmenu = await openSettingsSubmenu(css.root, 'Speed'); const cssContract = await popupContract(css.root, cssSubmenu); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindSubmenu = await openSettingsSubmenu(tailwind.root, 'Speed'); const tailwindContract = await popupContract(tailwind.root, tailwindSubmenu); @@ -282,7 +264,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800); const cssSubmenu = await openSettingsSubmenu(css.root, 'Captions'); const cssContract = await popupContract(css.root, cssSubmenu); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindSubmenu = await openSettingsSubmenu(tailwind.root, 'Captions'); const tailwindContract = await popupContract(tailwind.root, tailwindSubmenu); @@ -294,8 +276,8 @@ for (const variant of CASES) { await page.clock.install(); const { css, tailwind } = await openVariants(page, variant, 800); - const cssContract = await keyboardFeedbackContract(css.root); - const tailwindContract = await keyboardFeedbackContract(tailwind.root); + const cssContract = await keyboardFeedbackContract(css); + const tailwindContract = await keyboardFeedbackContract(tailwind); expect(tailwindContract).toEqual(cssContract); }); @@ -321,7 +303,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800); await enableCaptions(css); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); await enableCaptions(tailwind); await expectSameRendering(testInfo, reference, tailwind.root); @@ -332,7 +314,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800); const cssSlider = await openSeekPreview(css.root); const cssContract = await sliderContract(cssSlider); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const cssAlignment = await sliderPreviewAlignment(cssSlider); expect(cssAlignment.every((offset) => Math.abs(offset) <= 1)).toBe(true); @@ -371,7 +353,7 @@ for (const variant of CASES) { }); expect(Math.abs(cssContainment.rootHeight - cssRootBox.height)).toBeLessThanOrEqual(1); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); await tailwind.root.evaluate((element) => { element.style.height = '180px'; @@ -396,7 +378,7 @@ for (const variant of CASES) { expect(cssContract.previewValueBottomInPreviewHeights).toBe(variant.skin === 'default-video' ? 11.5 : 6); - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const cssPreview = variant.skin === 'minimal-video' ? await fullscreenPreviewContract(css.root) : null; const cssMenu = await fullscreenSpeedMenuContract(css.root); @@ -414,9 +396,10 @@ for (const variant of CASES) { expect(tailwindMenu).toEqual(cssMenu); expect(tailwindMenu).toEqual({ heightInSpacingUnits: 56, maxHeightInSpacingUnits: 56, scrolls: true }); + // Gaps measured once Inter has loaded the label; the fallback face reads about two pixels larger. if (tailwindPreview) { expect(tailwindPreview.timeToSliderGap).toBeGreaterThanOrEqual(14); - expect(tailwindPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(8); + expect(tailwindPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(6); } await exitFullscreen(page); @@ -440,7 +423,7 @@ test('minimal fullscreen geometry scales through the large breakpoints', async ( expect(cssFullscreen.scale).toBe(scale); expect(cssPreview.timeToSliderGap).toBeGreaterThanOrEqual(17); - expect(cssPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(10); + expect(cssPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(7); expect(cssMenu).toEqual({ heightInSpacingUnits: 56, maxHeightInSpacingUnits: 56, scrolls: true }); await exitFullscreen(page); @@ -470,7 +453,7 @@ for (const skin of ['default-video', 'minimal-video'] as const) { expect(cssContract.menu.backdropFilter).toBe('none'); } - const reference = await snapshotReference(css.root, name); + const reference = await captureRendering(css.root, name); const tailwindMenu = await openSettingsMenu(tailwind.root); const tailwindContract = await preferenceSurfaceContract(tailwind.root, tailwindMenu); @@ -480,21 +463,6 @@ for (const skin of ['default-video', 'minimal-video'] as const) { } } -test('semantic CSS stays easy to override from unlayered consumer styles', async ({ page }) => { - const { css } = await openVariants(page, REACT_DEFAULT, 800); - - // The consumer stylesheet has to land in the frame the player renders in. - await css.frame.addStyleTag({ - content: '.media-play-button { width: 44px; height: 44px; background: rgb(18 52 86); }', - }); - - const play = css.root.getByRole('button', { name: 'Play' }); - - await expect(play).toHaveCSS('width', '44px'); - await expect(play).toHaveCSS('height', '44px'); - await expect(play).toHaveCSS('background-color', 'rgb(18, 52, 86)'); -}); - test('React chapter segments match across styles and retain their generated range props', async ({ page }) => { const { panels } = await openComparison(page, { ...REACT_DEFAULT, media: 'hls-7', width: 855 }, async ({ root }) => expect(root).toBeVisible() @@ -870,18 +838,27 @@ async function pressedButtonContract(button: Locator) { await button.hover(); await page.mouse.down(); await page.waitForTimeout(200); + await settleAnimations(button); const contract = await buttonStateContract(button); await page.mouse.up(); return contract; } +/** Wait for the element's running transitions to finish, so a contract reads settled values rather than a frame of them. */ +async function settleAnimations(target: Locator) { + await target.evaluate((element) => + Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished.catch(() => undefined))) + ); +} + async function seekFocusContract(root: Locator) { const thumb = root.getByRole('slider', { name: 'Seek' }); await thumb.focus(); await expect(thumb).toBeFocused(); await root.page().waitForTimeout(200); + await settleAnimations(thumb); return thumb.evaluate((element) => { const style = getComputedStyle(element); @@ -1142,7 +1119,14 @@ async function fullscreenPreviewContract(root: Locator) { const thumbnail = slider.locator(':scope > :last-child > :first-child'); + // The thumbnail keeps a placeholder box until its storyboard image arrives, so the gaps depend on the loaded image, + // and the preview scales in, so they also depend on the transition having finished. + await expect(thumbnail).not.toHaveAttribute('data-loading', '', { timeout: 20_000 }); await expect.poll(() => thumbnail.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(0); + await settleAnimations(slider.locator(':scope > :last-child')); + // The time label appears with the preview and is the first use of its weight, so its box follows the fallback font's + // metrics until Inter has loaded it. + await settleFonts(slider); return slider.evaluate((element) => { const preview = element.lastElementChild; @@ -1230,7 +1214,13 @@ async function preferenceSurfaceContract(root: Locator, menu: Locator) { }; } -async function keyboardFeedbackContract(root: Locator) { +async function keyboardFeedbackContract(panel: SkinPanel) { + const { root } = panel; + + // Inter loads one weight at a time, so a label still in its fallback face centers a fraction of a pixel differently. + await settleFonts(root); + await presetVolume(panel); + return { volume: await triggerIndicator(root, 'ArrowUp', '[data-level]:not([role])'), captions: await triggerIndicator(root, 'c', '[data-status="captions-on"], [data-status="captions-off"]'), @@ -1250,6 +1240,8 @@ async function triggerIndicator(root: Locator, key: string, selector: string) { await expect(indicator).toBeVisible(); await expect(indicator).not.toHaveAttribute('data-starting-style', ''); + // The indicator's label is the first use of its weight, so Inter starts loading it now, not when the page loaded. + await settleFonts(indicator); const contract = await indicatorContract(indicator); await page.clock.runFor(1_000); @@ -1262,6 +1254,8 @@ async function indicatorContract(indicator: Locator) { if (!(element instanceof HTMLElement)) throw new Error('Expected an HTML status indicator.'); const round = (value: number) => Math.round(value); + // Centered indicators land on sub-pixel offsets that follow glyph widths; whole pixels are what the styling owns. + const roundPx = (value: string) => (value.endsWith('px') ? `${round(Number.parseFloat(value))}px` : value); const inspect = (target: Element | null) => { if (!(target instanceof HTMLElement || target instanceof SVGElement)) return null; @@ -1281,7 +1275,12 @@ async function indicatorContract(indicator: Locator) { fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight, - inset: { top: style.top, right: style.right, bottom: style.bottom, left: style.left }, + inset: { + top: roundPx(style.top), + right: roundPx(style.right), + bottom: roundPx(style.bottom), + left: roundPx(style.left), + }, motion: { animationName: style.animationName, transitionDuration: style.transitionDuration, @@ -1294,6 +1293,8 @@ async function indicatorContract(indicator: Locator) { width: round(width), height: round(height), }, + // The level or status the indicator announces; a width difference alone does not say which panel diverged. + text: target.textContent?.trim() ?? '', }; }; const inspectProgress = (content: Element | null, progress: Element | undefined) => { @@ -1698,6 +1699,8 @@ async function accentContrastContract(root: Locator, accent: string) { await target.hover(); await root.page().waitForTimeout(200); + // The color transitions in oklab, and a computed color read mid-transition serializes in that space. + await settleAnimations(target); return target.evaluate((element) => { const style = getComputedStyle(element); diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-320-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-320-vjsc-chromium.png deleted file mode 100644 index 21a3649100..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-320-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-800-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-800-vjsc-chromium.png deleted file mode 100644 index ab1c83e0f0..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-800-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-buffering-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-buffering-vjsc-chromium.png deleted file mode 100644 index be28b17365..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-buffering-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-button-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-button-focus-vjsc-chromium.png deleted file mode 100644 index 68a91504fb..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-button-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-cue-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-cue-vjsc-chromium.png deleted file mode 100644 index 967c2f873e..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-cue-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-menu-vjsc-chromium.png deleted file mode 100644 index f0426d8cf3..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-captions-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png deleted file mode 100644 index 5d7ed50853..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index a577192ed9..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-hidden-controls-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-hidden-controls-vjsc-chromium.png deleted file mode 100644 index 4ebcfd6e2c..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-hidden-controls-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-play-tooltip-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-play-tooltip-vjsc-chromium.png deleted file mode 100644 index 9de5d3304e..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-play-tooltip-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-focus-vjsc-chromium.png deleted file mode 100644 index 65b8534f75..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-preview-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-preview-vjsc-chromium.png deleted file mode 100644 index fd6de2a38c..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-seek-preview-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-settings-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-settings-menu-vjsc-chromium.png deleted file mode 100644 index 56d2bbf507..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-settings-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-speed-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-speed-menu-vjsc-chromium.png deleted file mode 100644 index 273b3a6d47..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-speed-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-volume-popover-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-volume-popover-vjsc-chromium.png deleted file mode 100644 index ac6647e254..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-volume-popover-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-320-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-320-vjsc-chromium.png deleted file mode 100644 index 6c78582da5..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-320-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-800-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-800-vjsc-chromium.png deleted file mode 100644 index 2a68bd1a0e..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-800-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-buffering-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-buffering-vjsc-chromium.png deleted file mode 100644 index 19c6b10674..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-buffering-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-button-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-button-focus-vjsc-chromium.png deleted file mode 100644 index 85346793a7..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-button-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-cue-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-cue-vjsc-chromium.png deleted file mode 100644 index 5619c2ee55..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-cue-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-menu-vjsc-chromium.png deleted file mode 100644 index 3bd26e53ab..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-captions-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png deleted file mode 100644 index 899390b503..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index 05d24c1637..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-hidden-controls-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-hidden-controls-vjsc-chromium.png deleted file mode 100644 index 3ca0e9f44d..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-hidden-controls-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-play-tooltip-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-play-tooltip-vjsc-chromium.png deleted file mode 100644 index f12e29f418..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-play-tooltip-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-focus-vjsc-chromium.png deleted file mode 100644 index c95bc4ef31..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-preview-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-preview-vjsc-chromium.png deleted file mode 100644 index 98a40d940c..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-seek-preview-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-settings-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-settings-menu-vjsc-chromium.png deleted file mode 100644 index 48ca999290..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-settings-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-speed-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-speed-menu-vjsc-chromium.png deleted file mode 100644 index b96ba65aa0..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-speed-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-volume-popover-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-volume-popover-vjsc-chromium.png deleted file mode 100644 index 510e659e1e..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-volume-popover-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-320-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-320-vjsc-chromium.png deleted file mode 100644 index 96288ad89f..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-320-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-800-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-800-vjsc-chromium.png deleted file mode 100644 index 96de3dcb73..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-800-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-buffering-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-buffering-vjsc-chromium.png deleted file mode 100644 index 31c73cea37..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-buffering-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-button-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-button-focus-vjsc-chromium.png deleted file mode 100644 index 7158a3eaa5..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-button-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-cue-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-cue-vjsc-chromium.png deleted file mode 100644 index a5081260a9..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-cue-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-menu-vjsc-chromium.png deleted file mode 100644 index ed37884c6f..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-captions-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-contrast-more-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-contrast-more-vjsc-chromium.png deleted file mode 100644 index 2392654a14..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-contrast-more-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png deleted file mode 100644 index 66b5f6f7a2..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-forced-colors-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-forced-colors-vjsc-chromium.png deleted file mode 100644 index 0e5a6a3e7a..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-forced-colors-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index aa5f08c594..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-hidden-controls-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-hidden-controls-vjsc-chromium.png deleted file mode 100644 index 4a0f8640ae..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-hidden-controls-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-play-tooltip-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-play-tooltip-vjsc-chromium.png deleted file mode 100644 index dcb4570826..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-play-tooltip-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-reduced-transparency-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-reduced-transparency-vjsc-chromium.png deleted file mode 100644 index 8c9128075a..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-reduced-transparency-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-focus-vjsc-chromium.png deleted file mode 100644 index 2a24925e97..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-preview-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-preview-vjsc-chromium.png deleted file mode 100644 index d07fa20c95..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-seek-preview-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-settings-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-settings-menu-vjsc-chromium.png deleted file mode 100644 index b7c331cefd..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-settings-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-speed-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-speed-menu-vjsc-chromium.png deleted file mode 100644 index f2a9777e4b..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-speed-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-volume-popover-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-volume-popover-vjsc-chromium.png deleted file mode 100644 index a9949c66bd..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-volume-popover-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-320-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-320-vjsc-chromium.png deleted file mode 100644 index b987f5ccdc..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-320-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-800-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-800-vjsc-chromium.png deleted file mode 100644 index de6bbd98d4..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-800-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-buffering-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-buffering-vjsc-chromium.png deleted file mode 100644 index 9b28a54354..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-buffering-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-button-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-button-focus-vjsc-chromium.png deleted file mode 100644 index 7c0ada08c0..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-button-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-cue-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-cue-vjsc-chromium.png deleted file mode 100644 index d9a19715c3..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-cue-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-menu-vjsc-chromium.png deleted file mode 100644 index 3b9e58cd88..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-captions-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-contrast-more-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-contrast-more-vjsc-chromium.png deleted file mode 100644 index 821f768b23..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-contrast-more-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png deleted file mode 100644 index 1cea2c5874..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-forced-colors-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-forced-colors-vjsc-chromium.png deleted file mode 100644 index bfcd456c98..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-forced-colors-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-fullscreen-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-fullscreen-vjsc-chromium.png deleted file mode 100644 index 63a41df5ca..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-fullscreen-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-hidden-controls-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-hidden-controls-vjsc-chromium.png deleted file mode 100644 index 4dfa0a18f2..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-hidden-controls-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-play-tooltip-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-play-tooltip-vjsc-chromium.png deleted file mode 100644 index 3ab8528eab..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-play-tooltip-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-reduced-transparency-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-reduced-transparency-vjsc-chromium.png deleted file mode 100644 index 821f768b23..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-reduced-transparency-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-focus-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-focus-vjsc-chromium.png deleted file mode 100644 index ae52a3fb8a..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-focus-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-preview-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-preview-vjsc-chromium.png deleted file mode 100644 index b7205cad40..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-seek-preview-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-settings-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-settings-menu-vjsc-chromium.png deleted file mode 100644 index 946b05473b..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-settings-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-speed-menu-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-speed-menu-vjsc-chromium.png deleted file mode 100644 index 39af15b382..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-speed-menu-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-volume-popover-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-volume-popover-vjsc-chromium.png deleted file mode 100644 index af1a9b213b..0000000000 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-volume-popover-vjsc-chromium.png and /dev/null differ diff --git a/apps/e2e/vite.config.ts b/apps/e2e/vite.config.ts index 4c718a9e81..0100b15502 100644 --- a/apps/e2e/vite.config.ts +++ b/apps/e2e/vite.config.ts @@ -15,7 +15,8 @@ export default defineConfig({ }, 'prepare:player': { command: 'pnpm generate-pages', - dependsOn: workspaceTaskDependencies(), + // The CDN pages import the bundles the cdn package packs, which its plain `build` task does not produce. + dependsOn: [...workspaceTaskDependencies(), '@videojs/cdn#build:cdn'], input: testInputs, output: ['suites/player/app/src/index.html', 'suites/player/app/src/pages/**'], }, diff --git a/packages/skins/src/styles/layout/container.styles.ts b/packages/skins/src/styles/layout/container.styles.ts index c60d5c3b42..8494c2b35a 100644 --- a/packages/skins/src/styles/layout/container.styles.ts +++ b/packages/skins/src/styles/layout/container.styles.ts @@ -18,6 +18,13 @@ export default styles({ 'after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit]', 'after:shadow-[inset_0_0_0_1px_var(--media-frame-border)] [&:fullscreen]:after:hidden', ], + variants: { + // The HTML skin slots the page's media, which the base `video` rule cannot reach across the shadow boundary. + 'shadow-dom': [ + '[&>slot::slotted(video)]:m-0 [&>slot::slotted(video)]:block [&>slot::slotted(video)]:h-full [&>slot::slotted(video)]:w-full', + '[&>slot::slotted(video)]:max-w-full [&>slot::slotted(video)]:rounded-[inherit] [&>slot::slotted(video)]:object-media', + ], + }, }, }, }); diff --git a/packages/skins/src/styles/sliders/thumbnail.styles.ts b/packages/skins/src/styles/sliders/thumbnail.styles.ts index cff740f496..d619318276 100644 --- a/packages/skins/src/styles/sliders/thumbnail.styles.ts +++ b/packages/skins/src/styles/sliders/thumbnail.styles.ts @@ -16,6 +16,8 @@ export default styles({ }, }, image: { + // The HTML thumbnail renders its image in a shadow root. + shadowHost: true, utilities: [ 'relative block max-h-(--media-slider-preview-max-height) max-w-(--media-slider-preview-max-width) overflow-clip rounded-[inherit]', 'transition-opacity duration-media-base ease-out', diff --git a/packages/skins/src/tests/container.test.ts b/packages/skins/src/tests/container.test.ts new file mode 100644 index 0000000000..ea27d15e12 --- /dev/null +++ b/packages/skins/src/tests/container.test.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vite-plus/test'; + +const containerStyles = new URL('../styles/layout/container.styles.ts', import.meta.url); + +describe('container styles', () => { + it('sizes the slotted video in the Shadow DOM', async () => { + const source = await readFile(containerStyles, 'utf8'); + + expect(source).toContain('[&>slot::slotted(video)]:h-full [&>slot::slotted(video)]:w-full'); + expect(source).toContain('[&>slot::slotted(video)]:object-media'); + }); +}); diff --git a/packages/vjsc/src/plugins/tests/style.test.ts b/packages/vjsc/src/plugins/tests/style.test.ts index e5074362cc..2f8dd4ce41 100644 --- a/packages/vjsc/src/plugins/tests/style.test.ts +++ b/packages/vjsc/src/plugins/tests/style.test.ts @@ -422,6 +422,7 @@ function rule(tokenPath: readonly string[], className: string, utilities: readon file: 'buttons.css', layer: 'videojs.components', scopeRoot: false, + shadowHost: false, utilityGroups: utilities, utilities, variantGroups: {}, diff --git a/packages/vjsc/src/styles/compile.ts b/packages/vjsc/src/styles/compile.ts index 1e2faf3ee3..000fe6d2d7 100644 --- a/packages/vjsc/src/styles/compile.ts +++ b/packages/vjsc/src/styles/compile.ts @@ -65,7 +65,14 @@ function compileKey( variants, options.ruleClassNames ? [...options.ruleClassNames] : null, [...groupOwners].sort(([left], [right]) => left.localeCompare(right)), - selected.map((rule) => [rule.className, rule.file, rule.layer, rule.scopeRoot, utilitiesForRule(rule, variants)]), + selected.map((rule) => [ + rule.className, + rule.file, + rule.layer, + rule.scopeRoot, + rule.shadowHost, + utilitiesForRule(rule, variants), + ]), options.ruleClassNames ? null : [...new Set(options.styles.rules.map((rule) => rule.file))].sort(), ]); } @@ -157,7 +164,7 @@ function compileRule(rule: ResolvedStyleRule, design: DesignSystem, variants: re ); } - return { className: rule.className, candidates, scopeRoot: rule.scopeRoot }; + return { className: rule.className, candidates, scopeRoot: rule.scopeRoot, shadowHost: rule.shadowHost }; } /** Each relationship marker must have exactly one owner before its consumers can be scoped to it. */ diff --git a/packages/vjsc/src/styles/define.ts b/packages/vjsc/src/styles/define.ts index f869b33375..d90924b44a 100644 --- a/packages/vjsc/src/styles/define.ts +++ b/packages/vjsc/src/styles/define.ts @@ -15,6 +15,11 @@ export interface StyleRule { readonly className?: string | undefined; /** Also match this class when it is colocated on the configured CSS scope root. */ readonly scopeRoot?: boolean | undefined; + /** + * The styled element hosts a shadow root. WebKit does not match `@scope` rules whose subject hosts a shadow root, so + * these rules are emitted outside the scope block with the scope root as an ancestor instead. + */ + readonly shadowHost?: boolean | undefined; /** Tailwind utilities shared by every configured variant. */ readonly utilities: StyleValue; /** Utilities appended when a source transform selects a matching variant. */ diff --git a/packages/vjsc/src/styles/output.ts b/packages/vjsc/src/styles/output.ts index c244bdffa5..70de73eb56 100644 --- a/packages/vjsc/src/styles/output.ts +++ b/packages/vjsc/src/styles/output.ts @@ -2,6 +2,7 @@ export interface StyleOutputRule { className: string; candidates: readonly string[]; scopeRoot: boolean; + shadowHost: boolean; } export interface StyleOutputFile { diff --git a/packages/vjsc/src/styles/render.ts b/packages/vjsc/src/styles/render.ts index 7d6ba4569c..134485f4cc 100644 --- a/packages/vjsc/src/styles/render.ts +++ b/packages/vjsc/src/styles/render.ts @@ -8,7 +8,7 @@ import { transform, } from 'lightningcss'; -import { cloneCssAst, collectRuleClasses, withoutNullValues } from './css-ast'; +import { cloneCssAst, collectRuleClasses, hasNestedCssRules, withoutNullValues } from './css-ast'; import type { DesignSystem } from './design-system'; import type { StyleOutputFile } from './output'; import { replaceRuleClasses } from './selectors'; @@ -55,7 +55,8 @@ export async function renderStylesheets(options: RenderStylesheetsOptions): Prom function wrapFileCss(css: string, scope: string | undefined, file: StyleOutputFile): string { const relationshipOwners = new Set(file.groupOwners.values()); const scopeRootClasses = new Set(file.rules.filter((rule) => rule.scopeRoot).map((rule) => rule.className)); - const split = scope ? splitSlottedRules(css) : { scoped: css, unscoped: '' }; + const shadowHostClasses = new Set(file.rules.filter((rule) => rule.shadowHost).map((rule) => rule.className)); + const split = scope ? splitUnscopedRules(css, scope, shadowHostClasses) : { scoped: css, unscoped: '' }; const scoped = scope ? `@scope (${scope}) {\n${split.scoped}\n}` : split.scoped; const wrapped = `@layer ${file.layer} {\n${scoped}\n${split.unscoped}\n}`; @@ -87,24 +88,77 @@ function wrapFileCss(css: string, scope: string | undefined, file: StyleOutputFi ); } -/** Slotted nodes sit outside a shadow tree's CSS scope, so keep their anchored selectors outside the outer scope. */ -function splitSlottedRules(css: string): { scoped: string; unscoped: string } { +/** + * Keep the rules `@scope` cannot serve outside the scope block. Slotted nodes sit outside a shadow tree's CSS scope, + * and WebKit never matches a scoped rule whose subject hosts a shadow root; those rules take the scope root as an + * ancestor instead. Conditional at-rules retain their conditions when their matching rules move. + */ +function splitUnscopedRules(css: string, scope: string, shadowHostClasses: ReadonlySet) { let hasSlottedRules = false; - const scoped = filterTopLevelRules(css, (rule) => { + let hasShadowHostRules = false; + const isShadowHostRule = (rule: Rule) => isShadowHostStyleRule(rule, shadowHostClasses); + const scoped = filterCssRules(css, (rule) => { const slotted = isSlottedStyleRule(rule); + const shadowHost = !slotted && isShadowHostRule(rule); hasSlottedRules ||= slotted; + hasShadowHostRules ||= shadowHost; - return !slotted; + return !slotted && !shadowHost; }); - return { - scoped, - unscoped: hasSlottedRules ? filterTopLevelRules(css, isSlottedStyleRule) : '', - }; + const slotted = hasSlottedRules ? filterCssRules(css, isSlottedStyleRule) : ''; + const shadowHosts = hasShadowHostRules ? prefixScope(filterCssRules(css, isShadowHostRule), scope) : ''; + + return { scoped, unscoped: `${slotted}\n${shadowHosts}` }; +} + +/** Prefix every selector with the scope root as a zero-specificity ancestor, standing in for the `@scope` block. */ +function prefixScope(css: string, scope: string): string { + const root = parseSelector(`:where(${scope})`); + + return decoder.decode( + transform({ + filename: 'shadow-hosts.css', + code: encoder.encode(css), + visitor: { + Rule: { + style(rule) { + const selectors = rule.value.selectors.map((selector) => [ + ...root.map(cloneCssAst), + { type: 'combinator', value: 'descendant' } as const, + ...selector.map(cloneCssAst), + ]); + + return withoutNullValues({ ...cloneCssAst(rule), value: { ...cloneCssAst(rule.value), selectors } }); + }, + }, + }, + }).code + ); +} + +function parseSelector(text: string): Selector { + let parsed: Selector | undefined; + + transform({ + filename: 'selector.css', + code: encoder.encode(`${text} { --vjsc: 0; }`), + visitor: { + Rule: { + style(rule) { + parsed = cloneCssAst(rule.value.selectors[0]); + }, + }, + }, + }); + + if (!parsed) throw new Error(`Could not parse the CSS scope selector '${text}'.`); + + return parsed; } -function filterTopLevelRules(css: string, include: (rule: Rule) => boolean): string { +function filterCssRules(css: string, include: (rule: Rule) => boolean): string { return decoder.decode( transform({ filename: 'semantic.css', @@ -113,7 +167,7 @@ function filterTopLevelRules(css: string, include: (rule: Rule) => boolean): str StyleSheet(stylesheet) { return withoutNullValues({ ...cloneCssAst(stylesheet), - rules: stylesheet.rules.filter(include).map((rule) => cloneCssAst(rule)), + rules: filterNestedRules(stylesheet.rules, include), }); }, }, @@ -121,6 +175,37 @@ function filterTopLevelRules(css: string, include: (rule: Rule) => boolean): str ); } +function filterNestedRules(rules: readonly Rule[], include: (rule: Rule) => boolean): Rule[] { + const filtered: Rule[] = []; + + for (const rule of rules) { + if (hasNestedCssRules(rule)) { + const nested = filterNestedRules(rule.value.rules, include); + + if (nested.length > 0) { + const cloned = cloneCssAst(rule); + + cloned.value.rules = nested; + filtered.push(withoutNullValues(cloned)); + } + + continue; + } + + if (include(rule)) filtered.push(cloneCssAst(rule)); + } + + return filtered; +} + +/** A rule whose selectors all start from a class of an element that hosts a shadow root. */ +function isShadowHostStyleRule(rule: Rule, shadowHostClasses: ReadonlySet): boolean { + return ( + rule.type === 'style' && + rule.value.selectors.every((selector) => selector[0]?.type === 'class' && shadowHostClasses.has(selector[0].name)) + ); +} + function isSlottedStyleRule(rule: Rule): boolean { return ( rule.type === 'style' && diff --git a/packages/vjsc/src/styles/resolved.ts b/packages/vjsc/src/styles/resolved.ts index 3bb0e177d3..2f8052cdd5 100644 --- a/packages/vjsc/src/styles/resolved.ts +++ b/packages/vjsc/src/styles/resolved.ts @@ -25,6 +25,7 @@ export interface ResolvedStyleRule { readonly file: string; readonly layer: string; readonly scopeRoot: boolean; + readonly shadowHost: boolean; readonly utilityGroups: readonly string[]; readonly utilities: readonly string[]; readonly variantGroups: Readonly>; @@ -190,6 +191,7 @@ function resolveModuleRules(definition: StyleDefinition, modulePath: string): Re file: definition.file, layer, scopeRoot: rule.scopeRoot ?? false, + shadowHost: rule.shadowHost ?? false, utilityGroups: Object.freeze(utilityGroups), utilities: Object.freeze(utilityGroups.flatMap(splitClassNames)), variantGroups: Object.freeze(variantGroups), diff --git a/packages/vjsc/src/styles/tests/compile.test.ts b/packages/vjsc/src/styles/tests/compile.test.ts index 5f7273ee5d..dc57c60000 100644 --- a/packages/vjsc/src/styles/tests/compile.test.ts +++ b/packages/vjsc/src/styles/tests/compile.test.ts @@ -177,6 +177,33 @@ describe('compileStyles', () => { expect(css).toContain('.media-poster > slot::slotted(img:not([src]))'); expect(css).toMatch(/}\s*\.media-poster > slot::slotted/); }); + + it('emits shadow host rules outside the scope without changing specificity or conditions', async () => { + const thumbnail = { + ...rule('image', 'media-thumbnail-image', ['block', 'data-loading:opacity-0', 'sm:flex']), + shadowHost: true, + }; + const spinner = rule('spinner', 'media-thumbnail-spinner', ['absolute']); + const styles = await compileStyles({ + design: await loadDesignSystem(designPath), + styles: resolvedStyles([thumbnail, spinner]), + scope: '.media-skin-video', + variants: [], + }); + const css = styles.get('buttons.css') ?? ''; + const scopeEnd = css.indexOf('\n }\n'); + const scoped = css.slice(0, scopeEnd); + const unscoped = css.slice(scopeEnd); + + expect(scoped).toContain('@scope (.media-skin-video)'); + expect(scoped).toContain('.media-thumbnail-spinner'); + expect(scoped).not.toContain('.media-thumbnail-image'); + expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image {'); + expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image[data-loading] {'); + expect(unscoped).toMatch( + /@media[^{}]+\{\s*:where\(\.media-skin-video\) \.media-thumbnail-image \{\s*display: flex;/ + ); + }); }); function rule( @@ -192,6 +219,7 @@ function rule( file: 'buttons.css', layer: 'videojs.components', scopeRoot: false, + shadowHost: false, utilityGroups: utilities, utilities, variantGroups, diff --git a/packages/vjsc/src/styles/tests/diagnostics.test.ts b/packages/vjsc/src/styles/tests/diagnostics.test.ts index 4e59fe7dd0..c5548ff6fd 100644 --- a/packages/vjsc/src/styles/tests/diagnostics.test.ts +++ b/packages/vjsc/src/styles/tests/diagnostics.test.ts @@ -126,6 +126,7 @@ function rule(token: string, utilities: readonly string[]): ResolvedStyleRule { file: 'test.css', layer: 'videojs.components', scopeRoot: false, + shadowHost: false, utilityGroups: utilities, utilities, variantGroups: {}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b0deee301..b3808cb999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@videojs/cdn': + specifier: workspace:* + version: link:../../packages/cdn '@videojs/core': specifier: workspace:* version: link:../../packages/core