Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
- run: npx playwright install-deps
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
- run: npm test -- --all
- run: npm run test:unit:all

- run: npm run manifest
- run: npm run build
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ jobs:
- run: npx playwright install-deps
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'

- run: npm test -- --all
- run: npm run test:unit:all
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
verbose: true
- run: npm run manifest
- run: npm run build
- run: npm run test:examples
Comment thread
cursor[bot] marked this conversation as resolved.

canary:
if: ${{ github.ref == 'refs/heads/main' }}
Expand Down
1 change: 1 addition & 0 deletions examples/vanilla/media-elements/mux-video.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta name="viewport" content="width=device-width" />
<title>Media Chrome &lt;mux-video&gt; Example</title>
<script type="module" src="../../../dist/index.js"></script>
<script type="module" src="../../../dist/menu/index.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@mux/mux-video/+esm"></script>
<style>
/** add styles to prevent CLS (Cumulative Layout Shift) */
Expand Down
15 changes: 8 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@
"watch:build": "run-p \"build:esm -- --watch=forever\" \"build:cjs -- --watch=forever\" \"build:iife:* -- --watch=forever\"",
"dev": "run-p watch serve",
"start": "npm run dev",
"test": "web-test-runner --coverage --config test/web-test-runner.config.js",
"serve": "wet serve --cors --livereload --redirect :examples/vanilla/ --log-level error"
"test": "npm run test:unit",
"test:unit": "web-test-runner --coverage --config test/web-test-runner.config.js",
"test:unit:all": "npm run test:unit -- --all",
"test:examples": "playwright test --config test/example-tests.config.ts",
"serve": "wet serve --cors --livereload --redirect :examples/vanilla/ --log-level error",
"serve:test": "wet serve --cors --redirect :examples/vanilla/ --log-level silent"
},
"repository": {
"type": "git",
Expand All @@ -111,6 +115,7 @@
"@custom-elements-manifest/analyzer": "^0.10.2",
"@open-wc/testing": "^3.1.6",
"@types/mocha": "^10.0.6",
"@types/node": "^25.6.0",
"@types/react": "19.2.2",
"@vercel/edge": "^1.2.1",
"@web/dev-server-esbuild": "^1.0.2",
Expand Down
28 changes: 28 additions & 0 deletions test/example-tests.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineConfig, devices } from 'playwright/test';

const PORT = 4567;

export default defineConfig({
testDir: './examples',
retries: 2,

webServer: {
command: `npm run serve:test -- -p ${PORT}`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
},

use: {
baseURL: `http://localhost:${PORT}`,
screenshot: "only-on-failure",
video: "off",
trace: "retain-on-failure",
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
6 changes: 6 additions & 0 deletions test/examples/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test, expect } from 'playwright/test';

test('examples index page loads', async ({ page }) => {
await page.goto('/examples/vanilla/');
await expect(page).toHaveTitle('Media Chrome Examples');
});
74 changes: 74 additions & 0 deletions test/examples/vanilla/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readdirSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath } from 'url';
import type { Page } from 'playwright/test';

const __dirname = dirname(fileURLToPath(import.meta.url));
export const EXAMPLES_DIR = join(__dirname, '../../../examples/vanilla');

export function findHtmlFiles(dir: string, base = dir): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
if (statSync(fullPath).isDirectory()) {
files.push(...findHtmlFiles(fullPath, base));
} else if (entry.endsWith('.html')) {
files.push(relative(base, fullPath));
}
}
return files.sort();
}

/**
* Replaces the first native <video> or <audio> element's source with a
* synthetic stream (canvas or AudioContext) so tests run without network
* requests and without a mediaErrorCode in the store.
*
* The store blocks all state-change requests when mediaErrorCode is non-null,
* which happens when the external video URL fails to load in CI.
*/
export async function injectSyntheticStream(page: Page): Promise<void> {
await page.evaluate(async () => {
const mediaEl = document.querySelector(
'video, audio'
) as HTMLVideoElement | HTMLAudioElement | null;

if (!mediaEl || mediaEl.tagName.includes('-')) return;

if (mediaEl.tagName === 'AUDIO') {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const dst = ctx.createMediaStreamDestination();
osc.frequency.value = 0; // silent
osc.connect(dst);
osc.start();
mediaEl.srcObject = dst.stream;
} else {
const canvas = document.createElement('canvas');
canvas.width = 2;
canvas.height = 2;
canvas.getContext('2d')!.fillRect(0, 0, 2, 2);
(mediaEl as HTMLVideoElement).srcObject = canvas.captureStream();
}

await new Promise<void>((resolve) => {
if (mediaEl.readyState >= 1) { resolve(); return; }
mediaEl.addEventListener('loadedmetadata', () => resolve(), { once: true });
setTimeout(resolve, 2000);
});
});
}

/**
* Pages excluded from play testing:
* - No media elements (index, memory-leak-tester, media-chrome-menu)
* - Not a real page (iframe embeds another example)
* - Intentionally shows error states (media-error-dialog)
*/
export const SKIP_PLAY_TEST = new Set([
'index.html',
'memory-leak-tester.html',
'iframe.html',
'control-elements/media-chrome-menu.html',
'control-elements/media-error-dialog.html',
]);
Comment thread
cursor[bot] marked this conversation as resolved.
60 changes: 60 additions & 0 deletions test/examples/vanilla/interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Interaction tests — scope: targeted examples
*
* Verifies that controls are actually wired up and respond
* correctly to user input. Tests are written against specific
* pages where the behaviour is unambiguous.
*
* Avoids full media-playback assertions (too flaky in CI). Instead it
* validates event dispatch, state attribute changes, and DOM geometry.
*/
import { test, expect } from 'playwright/test';
import { injectSyntheticStream } from './helpers.js';

test.describe('standalone-controls.html', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/examples/vanilla/standalone-controls.html', {
waitUntil: 'load',
});
});

test('media-play-button dispatches play request when clicked', async ({
page,
}) => {
await page.evaluate(() => {
(window as any).__playRequestFired = false;
document.addEventListener(
'mediaplayrequest',
() => { (window as any).__playRequestFired = true; },
{ once: true, capture: true }
);
});

await page.locator('media-play-button').first().click();

const fired = await page.evaluate(() => (window as any).__playRequestFired);
expect(fired, 'mediaplayrequest event was not dispatched').toBe(true);
});

test('media-mute-button toggles mute state', async ({ page }) => {
await injectSyntheticStream(page);

const muteBtn = page.locator('media-mute-button').first();

await expect(muteBtn).not.toHaveAttribute('mediavolumelevel', 'off');
await muteBtn.click();
await expect(muteBtn).toHaveAttribute('mediavolumelevel', 'off');
await muteBtn.click();
await expect(muteBtn).not.toHaveAttribute('mediavolumelevel', 'off');
});

test('media-time-range is rendered with measurable width', async ({
page,
}) => {
const timeRange = page.locator('media-time-range').first();
const box = await timeRange.boundingBox();

expect(box, 'media-time-range is not visible').not.toBeNull();
expect(box!.width, 'media-time-range has zero width').toBeGreaterThan(0);
});
});
46 changes: 46 additions & 0 deletions test/examples/vanilla/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Smoke tests — scope: all example pages
*
* Checks that every page:
* 1. Loads without uncaught JS errors
* 2. Has all media-* custom elements properly registered
*
* This is the fastest / widest safety net. A failure here means a broken
* import or missing component registration — the most common regression type.
*/
import { test, expect } from 'playwright/test';
import { findHtmlFiles, EXAMPLES_DIR } from './helpers.js';

const htmlFiles = findHtmlFiles(EXAMPLES_DIR);

for (const relPath of htmlFiles) {
test(`vanilla/${relPath} - custom elements registered`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));

await page.goto(`/examples/vanilla/${relPath}`, { waitUntil: 'load' });

// Under parallel server load, module scripts can finish fetching slightly after
// `waitUntil: 'load'`. Wait up to 5s for every media-* element to be defined
// before asserting, so the check is not racy.
const undefinedElements = await page.evaluate(async () => {
const tagNames = new Set(
[...document.querySelectorAll('*')]
.map((el) => el.tagName.toLowerCase())
.filter((tag) => tag.startsWith('media-'))
);
await Promise.race([
Promise.all([...tagNames].map((tag) => customElements.whenDefined(tag))),
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
]);
return [...tagNames].filter((tag) => !customElements.get(tag));
});

expect(
undefinedElements,
`Unregistered custom elements: ${undefinedElements.join(', ')}`
).toHaveLength(0);

expect(pageErrors, `Page errors: ${pageErrors.join('; ')}`).toHaveLength(0);
});
}
13 changes: 13 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2020",
"module": "es2020",
"moduleResolution": "bundler",
"types": ["node"],
"lib": ["es2020", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["./**/*"]
}
Loading