diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b8778cf58..28949c9090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,26 +12,10 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Every job installs from the pnpm store cache on its own. A shared warm-up job would only help the rare lockfile +# change, and gating on it costs every run the warm-up's wall time; on a miss the parallel installs race to save the +# cache and the losers log a warning. jobs: - install: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Setup pnpm - uses: pnpm/action-setup@v6.1.0 - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version-file: '.nvmrc' - cache: pnpm - - - name: Install dependencies (cache warm-up) - run: pnpm install --frozen-lockfile - package_test_selection: name: Select package tests runs-on: ubuntu-latest @@ -80,7 +64,6 @@ jobs: } >> "$GITHUB_OUTPUT" lint: - needs: install runs-on: ubuntu-latest steps: @@ -106,7 +89,6 @@ jobs: run: pnpm exec vp test run tools/oxlint/anti-slop/rules/tests typecheck: - needs: install runs-on: ubuntu-latest steps: @@ -140,7 +122,7 @@ jobs: run: pnpm typecheck test: - needs: [install, package_test_selection] + needs: package_test_selection if: needs.package_test_selection.outputs.has_standard_tests == 'true' name: Test packages (${{ matrix.name }}) runs-on: ubuntu-latest @@ -204,9 +186,10 @@ jobs: uses: actions/setup-node@v5 with: node-version-file: '.nvmrc' + cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --prefer-offline - name: Restore Vite Task cache uses: actions/cache/restore@v6 @@ -220,7 +203,6 @@ jobs: run: .github/scripts/run-logged.sh test-spf pnpm exec vp run @videojs/spf#test:ci workspace: - needs: install runs-on: ubuntu-latest steps: @@ -243,7 +225,6 @@ jobs: run: pnpm check:workspace build: - needs: install runs-on: ubuntu-latest steps: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c1e2c8b3f5..7e0117f0ae 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -87,7 +87,7 @@ jobs: key: ${{ steps.vite-task-cache.outputs.cache-primary-key }} e2e: - name: E2E (${{ matrix.browser }}) + name: E2E (${{ matrix.browser }}${{ matrix.shards > 1 && format(' {0}/{1}', matrix.shard, matrix.shards) || '' }}) 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 @@ -96,9 +96,9 @@ jobs: timeout-minutes: 15 strategy: fail-fast: false - matrix: - # WebKit doubles the wall time, so pull requests run Chromium only and main covers both. - browser: ${{ fromJSON(github.event_name == 'push' && '["chromium", "webkit"]' || '["chromium"]') }} + # WebKit takes more than twice Chromium's wall time, so pull requests run Chromium only and main covers both, + # splitting WebKit in two so it finishes with the other jobs. + matrix: ${{ fromJSON(github.event_name == 'push' && '{"include":[{"browser":"chromium","shard":1,"shards":1},{"browser":"webkit","shard":1,"shards":2},{"browser":"webkit","shard":2,"shards":2}]}' || '{"include":[{"browser":"chromium","shard":1,"shards":1}]}') }} steps: - name: Checkout code @@ -129,13 +129,13 @@ jobs: 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 }} + run: pnpm --dir apps/e2e exec playwright test --config suites/player/playwright.config.ts --project=vite-${{ matrix.browser }} --shard=${{ matrix.shard }}/${{ matrix.shards }} - name: Upload test report if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report-${{ matrix.browser }} + name: playwright-report-${{ matrix.browser }}${{ matrix.shards > 1 && format('-{0}', matrix.shard) || '' }} path: apps/e2e/playwright-report/ retention-days: 14 @@ -143,7 +143,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-traces-${{ matrix.browser }} + name: playwright-traces-${{ matrix.browser }}${{ matrix.shards > 1 && format('-{0}', matrix.shard) || '' }} path: apps/e2e/test-results/ retention-days: 7 @@ -201,7 +201,7 @@ jobs: retention-days: 7 skin-parity: - name: Skin parity (${{ matrix.preset }}) + name: Skin parity (${{ matrix.preset }}${{ matrix.shards > 1 && format(' {0}/{1}', matrix.shard, matrix.shards) || '' }}) 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 @@ -211,7 +211,13 @@ jobs: strategy: fail-fast: false matrix: - preset: [video, live-video, audio, live-audio] + # The video preset carries twice the cases of the others, so it runs as two slices. + include: + - { preset: video, shard: 1, shards: 2 } + - { preset: video, shard: 2, shards: 2 } + - { preset: live-video, shard: 1, shards: 1 } + - { preset: audio, shard: 1, shards: 1 } + - { preset: live-audio, shard: 1, shards: 1 } steps: - name: Checkout code @@ -242,12 +248,13 @@ jobs: run: pnpm exec vp run '@videojs/e2e#test:skin-parity' env: VJSC_SKIN_PRESET: ${{ matrix.preset }} + VJSC_SKIN_SHARD: ${{ matrix.shard }}/${{ matrix.shards }} - name: Upload test report if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report-skin-parity-${{ matrix.preset }} + name: playwright-report-skin-parity-${{ matrix.preset }}${{ matrix.shards > 1 && format('-{0}', matrix.shard) || '' }} path: apps/e2e/playwright-report/skin-parity/ retention-days: 14 @@ -255,7 +262,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-traces-skin-parity-${{ matrix.preset }} + name: playwright-traces-skin-parity-${{ matrix.preset }}${{ matrix.shards > 1 && format('-{0}', matrix.shard) || '' }} path: apps/e2e/test-results/skin-parity/ retention-days: 7 @@ -316,7 +323,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.59.1-noble - timeout-minutes: 10 + timeout-minutes: 15 env: PLAYWRIGHT_BROWSERS_PATH: /ms-playwright diff --git a/apps/e2e/shared/playwright.ts b/apps/e2e/shared/playwright.ts index 44693bd5ca..759359d631 100644 --- a/apps/e2e/shared/playwright.ts +++ b/apps/e2e/shared/playwright.ts @@ -5,6 +5,12 @@ 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; +/** + * Under the 15-minute job timeout in `e2e.yml`, with room for the job's own setup. When the runner cancels a job the + * conclusion is `cancelled` rather than `failure`, so no report uploads and failure triage never runs; Playwright + * ending the run itself keeps both. + */ +const GLOBAL_TIMEOUT = 12 * 60_000; const e2eDir = resolve(import.meta.dirname, '..'); /** Apply the shared reporting, retry, trace, and screenshot policy to one E2E suite. */ @@ -13,6 +19,7 @@ export function suiteConfig(name: string): PlaywrightTestConfig { snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', outputDir: resolve(e2eDir, 'test-results', name), timeout: 60_000, + globalTimeout: CI ? GLOBAL_TIMEOUT : 0, retries: CI ? RETRIES : 0, fullyParallel: true, reporter: CI diff --git a/apps/e2e/suites/registry/setup/global.ts b/apps/e2e/suites/registry/setup/global.ts index 895b1326ac..b37c31c812 100644 --- a/apps/e2e/suites/registry/setup/global.ts +++ b/apps/e2e/suites/registry/setup/global.ts @@ -151,7 +151,7 @@ async function configurePackage(projectDir: string, overrides: Readonly [name, overrides[name]])), diff --git a/apps/e2e/suites/skin-parity/playwright.config.ts b/apps/e2e/suites/skin-parity/playwright.config.ts index c0a9fce03c..864fb57e6e 100644 --- a/apps/e2e/suites/skin-parity/playwright.config.ts +++ b/apps/e2e/suites/skin-parity/playwright.config.ts @@ -6,6 +6,8 @@ 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; +/** The video preset carries twice the cases of the others, so CI splits it further as `current/total`. */ +const shard = /^(\d+)\/(\d+)$/.exec(process.env.VJSC_SKIN_SHARD ?? ''); /** * Generator correctness: one authored skin rendered through CSS and through Tailwind, and through the authored and the @@ -16,6 +18,7 @@ export default defineConfig({ ...suiteConfig('skin-parity'), testDir: resolve(import.meta.dirname, 'tests'), testMatch: preset ? `**/vjsc-${preset}-skin-styling.spec.ts` : '**/*.spec.ts', + shard: shard ? { current: Number(shard[1]), total: Number(shard[2]) } : null, // The warm-up compiles every skin and the Tailwind entry before the first case, so workers never race cold transforms. globalSetup: resolve(import.meta.dirname, 'setup/global.ts'), workers: process.env.CI ? 2 : 4, 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 dd80557fc1..e96ba0c850 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 @@ -25,6 +25,19 @@ const WIDTHS = [320, 800] as const; const BUFFERING_INDICATOR_SELECTOR = '.media-buffering-indicator, media-buffering-indicator, [class~="peer/buffering"], [class~="hidden"][class~="place-content-center"]'; const CONTROLS_SELECTOR = '.video-controls'; +/** + * The poster root in every skin: the HTML element, or the React `div` carrying `data-loaded`, which no other component + * exposes. The Tailwind output emits no semantic class for it, so a class selector misses that panel. `preparePanel` + * waits for the loaded state, so contracts can rely on the attribute being present. + */ +const POSTER_SELECTOR = 'media-poster, [data-loaded]'; +const LAYOUT_SELECTORS = [CONTROLS_SELECTOR, POSTER_SELECTOR] as const; +/** + * The spinner icon inside the slider preview's thumbnail, relative to the slider root. Playwright's child combinator + * pierces the `` shadow root, where a `` is also a last child, so the element type keeps + * the match to the icon. + */ +const THUMBNAIL_SPINNER_SELECTOR = ':scope > :last-child > :first-child > :is(svg, media-icon)'; for (const variant of CASES) { test(`${variant.framework} ${variant.skin} keeps poster sizing and fit in sync`, async ({ page }) => { @@ -745,7 +758,7 @@ async function preparePanel({ root }: SkinPanel, width: number) { await expect(root).toHaveAttribute('data-controls-visible', ''); await expect(root.getByRole('button', { name: 'Play' })).toBeVisible(); - const poster = root.locator('.media-poster[data-loaded], media-poster[data-loaded]').first(); + const poster = root.locator('[data-loaded]').first(); await expect(poster).toBeVisible(); await expect(poster).toHaveCSS('opacity', '1'); @@ -753,6 +766,9 @@ async function preparePanel({ root }: SkinPanel, width: number) { 'data-availability', 'available' ); + // Controls render before media metadata: the seek slider stays disabled, time text shows placeholders, and + // picture-in-picture hides until it lands. Both panels must be past that phase before any contract is compared. + await expect(root.getByRole('slider', { name: 'Seek' })).toBeEnabled({ timeout: 20_000 }); await root.dispatchEvent('pointermove', { pointerType: 'mouse' }); await expect .poll(() => @@ -1458,8 +1474,24 @@ async function enableCaptions({ root, section }: SkinPanel) { element.pause(); element.currentTime = 2; }); + // Cues activate when the seek completes, which waits on media data from the network while the showing track fetches + // its cues in parallel. The media may also carry a thumbnails metadata track, so look the caption track up by kind. await expect - .poll(() => video.evaluate((element: HTMLVideoElement) => element.textTracks[0]?.activeCues?.length ?? 0)) + .poll( + () => + video.evaluate((element: HTMLVideoElement) => { + const track = [...element.textTracks].find( + ({ kind, mode }) => (kind === 'subtitles' || kind === 'captions') && mode === 'showing' + ); + const active = track?.activeCues?.length ?? 0; + + // Cues that land after the seek completed only activate on the next one. + if (track?.cues?.length && !active && !element.seeking) element.currentTime = 2; + + return active; + }), + { timeout: 20_000 } + ) .toBeGreaterThan(0); await root.page().waitForTimeout(100); } @@ -1545,7 +1577,7 @@ async function sharedMotionContract(root: Locator) { }); const controls = root.locator(CONTROLS_SELECTOR).first(); const button = root.getByRole('button', { name: 'Play', exact: true }); - const poster = root.locator(':scope > .media-poster, :scope > media-poster').first(); + const poster = root.locator(POSTER_SELECTOR).first(); const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first(); const playIconCandidates = await root .getByRole('button', { name: 'Play', exact: true }) @@ -1587,7 +1619,7 @@ async function sharedMotionContract(root: Locator) { const fill = chapterTrack.locator(':scope > :last-child'); const preview = slider.locator(':scope > :last-child > :last-child'); const previewRoot = slider.locator(':scope > :last-child'); - const thumbnailSpinner = slider.locator(':scope > :last-child > :first-child > :last-child'); + const thumbnailSpinner = slider.locator(THUMBNAIL_SPINNER_SELECTOR); const pseudoTransition = (target: Locator, pseudo: '::before' | '::after') => target.evaluate((element, pseudoElement) => { const style = getComputedStyle(element, pseudoElement); @@ -1892,7 +1924,7 @@ async function reducedMotionContract(root: Locator, menu: Locator, tooltipDurati }) ) ); - const poster = root.locator(':scope > .media-poster, :scope > media-poster').first(); + const poster = root.locator(POSTER_SELECTOR).first(); const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first(); const seekThumb = root.getByRole('slider', { name: 'Seek' }); const seekSlider = seekThumb.locator('..'); @@ -1903,7 +1935,7 @@ async function reducedMotionContract(root: Locator, menu: Locator, tooltipDurati .first(); const fill = chapterTrack.locator(':scope > :last-child'); const preview = seekSlider.locator(':scope > :last-child > :last-child'); - const thumbnailSpinner = seekSlider.locator(':scope > :last-child > :first-child > :last-child'); + const thumbnailSpinner = seekSlider.locator(THUMBNAIL_SPINNER_SELECTOR); const thumbnailSpinnerMotion = await inspect(thumbnailSpinner); const rootMotion = { @@ -1958,7 +1990,7 @@ async function rtlMenuContract(root: Locator, submenu: Locator) { } async function layoutContract(root: Locator) { - return root.evaluate((element, controlsSelector) => { + return root.evaluate((element, [controlsSelector, posterSelector]) => { const rootRect = element.getBoundingClientRect(); const round = (value: number) => Math.round(value * 10) / 10; const inspect = ( @@ -2006,7 +2038,7 @@ async function layoutContract(root: Locator) { return { root: inspect(element), - poster: inspect(query('.media-poster[data-loaded], media-poster[data-loaded]'), { includeRadius: false }), + poster: inspect(query(posterSelector), { includeRadius: false }), controls: inspect(query(controlsSelector), { includeGap: false }), primary: inspect(play?.parentElement ?? null, { includeGap: false }), timeline: inspect(seek?.parentElement?.parentElement ?? null, { @@ -2028,7 +2060,7 @@ async function layoutContract(root: Locator) { query('[role="button"][aria-label="Enter fullscreen"], [role="button"][aria-label="Exit fullscreen"]') ), }; - }, CONTROLS_SELECTOR); + }, LAYOUT_SELECTORS); } async function openSettingsMenu(root: Locator): Promise { @@ -2476,7 +2508,7 @@ async function popupContract(root: Locator, popup: Locator) { } async function skinContract(root: Locator) { - return root.evaluate((element: HTMLElement) => { + return root.evaluate((element: HTMLElement, posterSelector) => { const rootRect = element.getBoundingClientRect(); const round = (value: number) => Math.round(value * 10) / 10; const relativeRect = (rect: DOMRect) => ({ @@ -2489,7 +2521,7 @@ async function skinContract(root: Locator) { const mute = element.querySelector('[role="button"][aria-label="Mute"]'); const seek = element.querySelector('[role="slider"][aria-label="Seek"]'); const controls = play?.closest('[data-interactive], media-controls'); - const poster = element.querySelector('.media-poster, media-poster'); + const poster = element.querySelector(posterSelector); const inspect = (target: HTMLElement | null | undefined) => { if (!target) return null; @@ -2532,7 +2564,7 @@ async function skinContract(root: Locator) { controlRadius: rootStyle.getPropertyValue('--media-control-radius').trim(), }, }; - }); + }, POSTER_SELECTOR); } function snapshotName(variant: SkinCase, width: number): string { diff --git a/apps/sandbox/package.json b/apps/sandbox/package.json index c64af6fcce..8fdde70979 100644 --- a/apps/sandbox/package.json +++ b/apps/sandbox/package.json @@ -62,7 +62,7 @@ "vite-plus": "catalog:", "vitest": "catalog:" }, - "packageManager": "pnpm@11.17.0", + "packageManager": "pnpm@12.3.4", "stackblitz": { "installDependencies": false, "startCommand": "pnpm install --ignore-scripts --config.nodeLinker=hoisted --config.blockExoticSubdeps=false && pnpm --config.verifyDepsBeforeRun=false exec vp run dev" diff --git a/apps/sandbox/scripts/sync-source-owned-skins.ts b/apps/sandbox/scripts/sync-source-owned-skins.ts index 268405fa5c..bf1af6acdc 100644 --- a/apps/sandbox/scripts/sync-source-owned-skins.ts +++ b/apps/sandbox/scripts/sync-source-owned-skins.ts @@ -29,7 +29,7 @@ const address = server if (server) { server.on('request', async (request, response) => { const path = new URL(request.url ?? '/', address).pathname.slice(1); - const source = await readFile(resolve(registryDir, path)).catch(() => undefined); + const source = await readFile(resolve(registryDir, path), 'utf8').catch(() => undefined); response.setHeader('access-control-allow-origin', '*'); response.setHeader('cache-control', 'no-store'); @@ -41,7 +41,7 @@ if (server) { } response.setHeader('content-type', 'application/json; charset=utf-8'); - response.end(source); + response.end(withoutPackagePins(source)); }); } @@ -86,6 +86,21 @@ if (existsSync(resolve(generatedDir, 'components/videojs/skins'))) { console.log(`Installed 8 React Tailwind, 8 React CSS, and 8 HTML source-owned Sandbox skins from ${address}.`); } +/** + * The local registry pins `@videojs/*` to the workspace version, which npm may not have yet, for instance on a release + * branch that bumped it. Only the copied source files matter here, so drop the pins: the fixture already lists those + * packages, and the Shadcn CLI skips installing an unpinned dependency that is present. + */ +function withoutPackagePins(source: string): string { + // SAFETY: the registry build validated these documents against the Shadcn schema; only `dependencies` changes. + const document = JSON.parse(source) as { dependencies?: string[] }; + if (!document.dependencies) return source; + + document.dependencies = document.dependencies.map((dependency) => dependency.replace(/^(@videojs\/[^@]+)@.+$/, '$1')); + + return `${JSON.stringify(document)}\n`; +} + /** What `app/styles.css` imports and scans, with nothing in it, so the app compiles without the registry skins. */ async function writeEmptyRegistry(): Promise { await rm(generatedDir, { recursive: true, force: true }); @@ -127,11 +142,12 @@ async function writeFixture(root: string, address: string, alias: string): Promi name: 'videojs-sandbox-skins', private: true, type: 'module', - packageManager: 'pnpm@11.17.0', + packageManager: 'pnpm@12.3.4', + // Every package the registry items depend on, so the CLI copies files without installing anything. dependencies: { '@videojs/core': '*', - '@videojs/html': '10.0.0-beta.32', - '@videojs/react': '10.0.0-beta.32', + '@videojs/html': '*', + '@videojs/react': '*', cn: '*', react: '*', },