Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,7 +64,6 @@ jobs:
} >> "$GITHUB_OUTPUT"

lint:
needs: install
runs-on: ubuntu-latest

steps:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -243,7 +225,6 @@ jobs:
run: pnpm check:workspace

build:
needs: install
runs-on: ubuntu-latest

steps:
Expand Down
31 changes: 19 additions & 12 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -129,21 +129,21 @@ 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

- name: Upload test traces
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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -242,20 +248,21 @@ 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

- name: Upload test traces
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

Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions apps/e2e/shared/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/e2e/suites/registry/setup/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ async function configurePackage(projectDir: string, overrides: Readonly<Record<s
{
...manifest,
private: true,
packageManager: 'pnpm@11.17.0',
packageManager: 'pnpm@12.3.4',
dependencies: {
...manifest.dependencies,
...Object.fromEntries(overlayDependencies.map((name) => [name, overrides[name]])),
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/suites/skin-parity/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
56 changes: 44 additions & 12 deletions apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<media-slider-thumbnail>` shadow root, where a `<slot>` 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 }) => {
Expand Down Expand Up @@ -745,14 +758,17 @@ 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');
await expect(root.getByRole('button', { name: /captions/i, includeHidden: true }).first()).toHaveAttribute(
'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(() =>
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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('..');
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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, {
Expand All @@ -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<Locator> {
Expand Down Expand Up @@ -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) => ({
Expand All @@ -2489,7 +2521,7 @@ async function skinContract(root: Locator) {
const mute = element.querySelector<HTMLElement>('[role="button"][aria-label="Mute"]');
const seek = element.querySelector<HTMLElement>('[role="slider"][aria-label="Seek"]');
const controls = play?.closest<HTMLElement>('[data-interactive], media-controls');
const poster = element.querySelector<HTMLElement>('.media-poster, media-poster');
const poster = element.querySelector<HTMLElement>(posterSelector);

const inspect = (target: HTMLElement | null | undefined) => {
if (!target) return null;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading