diff --git a/package.json b/package.json index 3c69fbb111..6426832e0e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:e2e:sandbox": "pnpm --dir apps/e2e test:sandbox", "test:e2e:update": "pnpm --dir apps/e2e test:update", "test:e2e:install": "pnpm --dir apps/e2e run install:browsers && pnpm --dir apps/e2e run install:deps", + "test:shadcn": "vp run @videojs/skins#validate:shadcn", "test:size": "node --test .github/scripts/tests/*.test.js", "format": "vp fmt --write", "format:astro": "vp fmt 'site/src/**/*.astro' --write", diff --git a/packages/core/src/dom/gesture/action-value.ts b/packages/core/src/dom/gesture/action-value.ts index f67fcd01cf..aad8eb95b5 100644 --- a/packages/core/src/dom/gesture/action-value.ts +++ b/packages/core/src/dom/gesture/action-value.ts @@ -1,7 +1,7 @@ import { isUndefined } from '@videojs/utils/predicate'; import { DEFAULT_SEEK_STEP } from '../../core/ui/constants'; -import { getMediaInputActionValue } from '../media-actions'; +import { getMediaInputActionValue } from '../input-action-value'; import type { GestureRegion } from './gesture'; /** Resolves the effective value for a gesture action from its explicit value and region. */ diff --git a/packages/core/src/dom/hotkey/hotkey.ts b/packages/core/src/dom/hotkey/hotkey.ts index 8fa6f6320d..7df37c76d4 100644 --- a/packages/core/src/dom/hotkey/hotkey.ts +++ b/packages/core/src/dom/hotkey/hotkey.ts @@ -1,7 +1,7 @@ import { isMacOS } from '@videojs/utils/dom'; import type { HotkeyProps } from '../../core/ui/hotkey/core'; -import { getMediaInputActionValue } from '../media-actions'; +import { getMediaInputActionValue } from '../input-action-value'; import { HotkeyCoordinator } from './coordinator'; export type HotkeyModifierKey = 'shift' | 'ctrl' | 'alt' | 'meta'; diff --git a/packages/core/src/dom/input-action-value.ts b/packages/core/src/dom/input-action-value.ts new file mode 100644 index 0000000000..6e2003090a --- /dev/null +++ b/packages/core/src/dom/input-action-value.ts @@ -0,0 +1,28 @@ +import { isUndefined } from '@videojs/utils/predicate'; + +import { DEFAULT_SEEK_STEP, DEFAULT_VOLUME_STEP } from '../core/ui/constants'; + +export type MediaInputActionName = 'seekStep' | 'volumeStep' | 'speedUp' | 'speedDown'; + +/** Resolve the numeric value for a media input action from an explicit value or its activating key. */ +export function getMediaInputActionValue( + action: string, + key: string | undefined, + value?: number | undefined +): number | undefined { + if (!isUndefined(value)) return value; + + const normalizedKey = key?.toLowerCase(); + + if (action === 'seekStep') { + return normalizedKey === 'arrowleft' || normalizedKey === 'j' ? -DEFAULT_SEEK_STEP : DEFAULT_SEEK_STEP; + } + + if (action === 'volumeStep') { + const step = DEFAULT_VOLUME_STEP / 100; + + return normalizedKey === 'arrowdown' ? -step : step; + } + + return undefined; +} diff --git a/packages/core/src/dom/media-actions.ts b/packages/core/src/dom/media-actions.ts index 1ac4ead1a4..6647f9b431 100644 --- a/packages/core/src/dom/media-actions.ts +++ b/packages/core/src/dom/media-actions.ts @@ -1,10 +1,10 @@ -import { isUndefined } from '@videojs/utils/predicate'; - -import { DEFAULT_SEEK_STEP, DEFAULT_VOLUME_STEP } from '../core/ui/constants'; +import { getMediaInputActionValue } from './input-action-value'; +import type { MediaInputActionName } from './input-action-value'; import type { AnyPlayerStore } from './player'; import { selectPlaybackRate, selectTime, selectVolume } from './store/selectors'; -export type MediaInputActionName = 'seekStep' | 'volumeStep' | 'speedUp' | 'speedDown'; +export { getMediaInputActionValue } from './input-action-value'; +export type { MediaInputActionName } from './input-action-value'; export interface MediaInputActionContext { store: AnyPlayerStore; @@ -14,28 +14,6 @@ export interface MediaInputActionContext { export type MediaInputActionResolver = (context: MediaInputActionContext) => void; -export function getMediaInputActionValue( - action: string, - key: string | undefined, - value?: number | undefined -): number | undefined { - if (!isUndefined(value)) return value; - - const normalizedKey = key?.toLowerCase(); - - if (action === 'seekStep') { - return normalizedKey === 'arrowleft' || normalizedKey === 'j' ? -DEFAULT_SEEK_STEP : DEFAULT_SEEK_STEP; - } - - if (action === 'volumeStep') { - const step = DEFAULT_VOLUME_STEP / 100; - - return normalizedKey === 'arrowdown' ? -step : step; - } - - return undefined; -} - export const MEDIA_INPUT_ACTION_OVERRIDES: Record = { seekStep({ store, value, key }) { const step = getMediaInputActionValue('seekStep', key, value)!; diff --git a/packages/skins/package.json b/packages/skins/package.json index 0b5bada758..f269bf899e 100644 --- a/packages/skins/package.json +++ b/packages/skins/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "@lit/context": "^1.1.0", + "@playwright/test": "^1.52.0", "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.18.6", "@types/react": "^19.2.17", diff --git a/packages/skins/scripts/validate-shadcn-registry.ts b/packages/skins/scripts/validate-shadcn-registry.ts new file mode 100644 index 0000000000..954da86917 --- /dev/null +++ b/packages/skins/scripts/validate-shadcn-registry.ts @@ -0,0 +1,822 @@ +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { relative, resolve, sep } from 'node:path'; + +import { chromium } from '@playwright/test'; +import { isPlainObject, isString } from '@videojs/utils/predicate'; +import { registryItemSchema, registrySchema, type RegistryItem } from 'shadcn/schema'; + +import { formatRegistrySource } from '../shadcn/format'; + +const packageDir = resolve(import.meta.dirname, '..'); +const workspaceDir = resolve(packageDir, '../..'); +const registryDir = resolve(packageDir, 'dist/registry'); +const sourceDir = resolve(registryDir, 'source'); +const hostedDir = resolve(registryDir, 'r'); +const shadcnBin = resolve(packageDir, 'node_modules/shadcn/dist/index.js'); +const generatedSource = /\.(?:css|[cm]?[jt]sx?)$/; +const videojsPackages = ['utils', 'element', 'store', 'media', 'spf', 'core', 'react'] as const; +const registryPackages = [...videojsPackages, 'html'] as const; +const catalogs = [ + { name: 'React Tailwind', path: 'react' }, + { name: 'React CSS', path: 'react/css' }, + { name: 'HTML Tailwind', path: 'html' }, + { name: 'HTML CSS', path: 'html/css' }, +] as const; + +const validatedItems = await Promise.all(catalogs.map(validateCatalog)); + +await assertPackagePins(validatedItems.flat()); +await assertIgnoredOutput(); + +const server = createServer(); +const address = await listen(server); + +server.on('request', async (request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? '/', address).pathname).replace(/^\/+/, ''); + const path = resolve(registryDir, pathname); + const source = path.startsWith(`${registryDir}${sep}`) ? await readFile(path).catch(() => undefined) : undefined; + + response.setHeader('access-control-allow-origin', '*'); + response.setHeader('cache-control', 'no-store'); + + if (!source) { + response.statusCode = 404; + response.end('Not found.'); + return; + } + + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(source); +}); + +const fixtureRoot = await mkdtemp(resolve(tmpdir(), 'videojs-shadcn-next-')); + +try { + await validateDiscovery(address); + + const tarballs = await packVideojsPackages(resolve(fixtureRoot, 'packages')); + + await validateNextFixture({ address, root: resolve(fixtureRoot, 'tailwind'), styling: 'tailwind', tarballs }); + await validateNextFixture({ address, root: resolve(fixtureRoot, 'css'), styling: 'css', tarballs }); +} finally { + await close(server); + + if (process.env.VIDEOJS_KEEP_SHADCN_FIXTURE === '1') { + console.log(`Kept Shadcn fixture at ${fixtureRoot}.`); + } else { + await rm(fixtureRoot, { recursive: true, force: true }); + } +} + +console.log('Validated all hosted catalogs and clean Next.js installs for React Tailwind and CSS.'); + +async function validateCatalog(catalog: (typeof catalogs)[number]): Promise { + const sourceRegistry = resolve(sourceDir, 'r', catalog.path, 'registry.json'); + const output = resolve(hostedDir, catalog.path); + const registry = registrySchema.parse(JSON.parse(await readFile(resolve(output, 'registry.json'), 'utf8'))); + const files = (await readdir(output, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort(); + const expected = ['registry.json', ...registry.items.map((item) => `${item.name}.json`)].sort(); + + if (files.join('\n') !== expected.join('\n')) { + throw new Error( + `${catalog.name} hosted files do not match its catalog.\nExpected: ${expected.length}\nActual: ${files.length}` + ); + } + + await runShadcn(['registry', 'validate', sourceRegistry, '--cwd', packageDir], packageDir); + + return Promise.all( + registry.items.map(async (manifest) => { + const item = registryItemSchema.parse( + JSON.parse(await readFile(resolve(output, `${manifest.name}.json`), 'utf8')) + ); + if (item.name !== manifest.name) throw new Error(`${catalog.name} item name mismatch: ${manifest.name}.`); + + for (const file of item.files ?? []) { + if (!file.content || !generatedSource.test(file.target ?? file.path)) continue; + + const formatted = await formatRegistrySource(file.target ?? file.path, file.content); + + if (formatted.errors.length > 0 || formatted.code !== file.content) { + throw new Error(`${catalog.name} source is not formatted: ${item.name}/${file.path}.`); + } + } + + return item; + }) + ); +} + +async function validateDiscovery(address: string): Promise { + const root = resolve(fixtureRoot, 'discovery'); + + await mkdir(root, { recursive: true }); + + for (const catalog of catalogs) { + const registryUrl = `${address}/r/${catalog.path}/registry.json`; + const search = await runShadcn( + ['search', registryUrl, '--query', '_style-theme', '--limit', '5', '--json', '--cwd', root], + root + ); + const result = JSON.parse(search.stdout); + + if (!isPlainObject(result) || !Array.isArray(result.items)) { + throw new Error(`${catalog.name} search did not return an item list.`); + } + + const names = result.items.flatMap((item) => (isPlainObject(item) && isString(item.name) ? [item.name] : [])); + if (!names.includes('_style-theme')) throw new Error(`${catalog.name} search did not return \`_style-theme\`.`); + + const view = await runShadcn(['view', `${address}/r/${catalog.path}/_style-theme.json`, '--cwd', root], root); + const viewed = registryItemSchema.array().parse(JSON.parse(view.stdout)); + + if (!viewed.some((item) => item.name === '_style-theme')) { + throw new Error(`${catalog.name} view did not return \`_style-theme\`.`); + } + } +} + +async function validateNextFixture(config: { + address: string; + root: string; + styling: 'tailwind' | 'css'; + tarballs: ReadonlyMap; +}): Promise { + await writeNextFixture(config); + await runCommand('pnpm', ['install', '--no-frozen-lockfile'], config.root); + + if (config.styling === 'tailwind') { + await runShadcn(['init', '--defaults', '--yes', '--silent', '--cwd', config.root], config.root); + } + + await configureRegistryNamespace(config); + + await add(config.root, ['@videojs/video']); + await assertInstalled(config.root, [ + 'components/videojs/skins/video/skin.tsx', + 'components/videojs/ui/play-button.tsx', + 'components/videojs/styles/theme.css', + 'lib/utils.ts', + ]); + + if (config.styling === 'css') { + await assertInstalled(config.root, [ + 'components/videojs/skins/video/skin.css', + 'components/videojs/styles/button.css', + ]); + } + + const sharedBefore = await sourceHashes(resolve(config.root, 'components/videojs/ui')); + + await add(config.root, ['@videojs/video-minimal']); + await assertInstalled(config.root, ['components/videojs/skins/video/minimal/skin.tsx']); + + const sharedAfter = await sourceHashes(resolve(config.root, 'components/videojs/ui')); + + if (JSON.stringify(sharedBefore) !== JSON.stringify(sharedAfter)) { + throw new Error(`${config.styling} minimal install changed shared component source.`); + } + + await installPackedDependencies(config.root, config.tarballs); + await writePlayer(config.root, config.styling); + await runCommand('pnpm', ['build'], config.root); + await runCommand('pnpm', ['lint'], config.root); + await validateRuntime(config.root, config.styling); +} + +async function installPackedDependencies(root: string, tarballs: ReadonlyMap): Promise { + const filename = resolve(root, 'package.json'); + const manifest = JSON.parse(await readFile(filename, 'utf8')); + + for (const name of Object.keys(manifest.dependencies)) { + const tarball = tarballs.get(name); + + if (tarball) manifest.dependencies[name] = tarball; + } + + await writeFile(filename, `${JSON.stringify(manifest, null, 2)}\n`); + await runCommand('pnpm', ['install', '--no-frozen-lockfile'], root); + + const compoundTypes = await readFile( + resolve(root, 'node_modules/@videojs/react/dist/dev/ui/audio-track-radio-group/index.parts.d.ts'), + 'utf8' + ); + + if (!compoundTypes.includes('Options')) { + throw new Error('The Next.js fixture did not install the packed @videojs/react artifact.'); + } +} + +async function writeNextFixture(config: { + address: string; + root: string; + styling: 'tailwind' | 'css'; + tarballs: ReadonlyMap; +}): Promise { + const overrides = Object.fromEntries(config.tarballs); + const coreTarball = requiredTarball(config.tarballs, '@videojs/core'); + const reactTarball = requiredTarball(config.tarballs, '@videojs/react'); + const devDependencies = { + '@types/node': '22.18.6', + '@types/react': '19.2.17', + '@types/react-dom': '19.2.3', + '@tailwindcss/postcss': config.styling === 'tailwind' ? '4.3.3' : undefined, + eslint: '^9.0.0', + 'eslint-config-next': '16.3.3', + tailwindcss: config.styling === 'tailwind' ? '4.3.3' : undefined, + typescript: '5.9.3', + }; + + const packageJson = { + name: `videojs-shadcn-${config.styling}-validation`, + private: true, + version: '0.0.0', + packageManager: 'pnpm@11.17.0', + scripts: { + build: 'next build', + lint: 'eslint .', + start: 'next start', + }, + dependencies: { + '@videojs/core': coreTarball, + '@videojs/react': reactTarball, + clsx: '2.1.1', + next: '16.3.3', + react: '19.2.8', + 'react-dom': '19.2.8', + 'tailwind-merge': '3.5.0', + }, + devDependencies, + }; + const components = { + $schema: 'https://ui.shadcn.com/schema.json', + style: 'new-york', + rsc: true, + tsx: true, + tailwind: { + config: '', + css: 'app/globals.css', + baseColor: 'neutral', + cssVariables: true, + prefix: '', + }, + iconLibrary: 'lucide', + aliases: { + components: '@/components', + ui: '@/components/ui', + utils: '@/lib/utils', + lib: '@/lib', + hooks: '@/hooks', + }, + registries: { + '@videojs': `${config.address}/r/react${config.styling === 'css' ? '/css' : ''}/{name}.json`, + }, + }; + const tsconfig = { + compilerOptions: { + target: 'ES2017', + lib: ['dom', 'dom.iterable', 'esnext'], + allowJs: true, + skipLibCheck: true, + strict: true, + noEmit: true, + esModuleInterop: true, + module: 'esnext', + moduleResolution: 'bundler', + resolveJsonModule: true, + isolatedModules: true, + jsx: 'react-jsx', + incremental: true, + plugins: [{ name: 'next' }], + paths: { '@/*': ['./*'] }, + }, + include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'], + exclude: ['node_modules'], + }; + + await mkdir(resolve(config.root, 'app'), { recursive: true }); + await writeFile(resolve(config.root, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + + if (config.styling === 'css') { + await writeFile(resolve(config.root, 'components.json'), `${JSON.stringify(components, null, 2)}\n`); + } + + await writeFile(resolve(config.root, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`); + await writeFile( + resolve(config.root, 'next.config.ts'), + `import type { NextConfig } from 'next';\n\nexport default {} satisfies NextConfig;\n` + ); + await writeFile(resolve(config.root, 'pnpm-workspace.yaml'), workspaceConfig(overrides)); + await writeFile( + resolve(config.root, 'next-env.d.ts'), + '/// \n/// \n' + ); + await writeFile( + resolve(config.root, 'eslint.config.mjs'), + `import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +export default defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']), +]); +` + ); + await writeFile( + resolve(config.root, 'app/layout.tsx'), + `import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +import './globals.css'; + +export const metadata: Metadata = { title: 'Video.js registry validation' }; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + + {children} + + ); +} +` + ); + await writeFile( + resolve(config.root, 'app/page.tsx'), + `import { Player } from './player'; + +export default function Page() { + return ( +
+

Video.js registry validation

+ +
+ ); +} +` + ); + await writeFile( + resolve(config.root, 'app/globals.css'), + config.styling === 'tailwind' + ? '@import "tailwindcss";\n' + : 'html { color-scheme: dark; }\nbody { margin: 0; background: #111; color: #fff; font-family: sans-serif; }\n' + ); + + if (config.styling === 'tailwind') { + await writeFile( + resolve(config.root, 'postcss.config.mjs'), + `export default { plugins: { '@tailwindcss/postcss': {} } };\n` + ); + } +} + +async function configureRegistryNamespace(config: { + address: string; + root: string; + styling: 'tailwind' | 'css'; +}): Promise { + const filename = resolve(config.root, 'components.json'); + const components = JSON.parse(await readFile(filename, 'utf8')); + + components.registries = { + ...components.registries, + '@videojs': `${config.address}/r/react${config.styling === 'css' ? '/css' : ''}/{name}.json`, + }; + + await writeFile(filename, `${JSON.stringify(components, null, 2)}\n`); +} + +async function writeHtmlFixture(config: { + address: string; + root: string; + styling: 'tailwind' | 'css'; + tarballs: ReadonlyMap; +}): Promise { + const overrides = Object.fromEntries(config.tarballs); + const htmlTarball = requiredTarball(config.tarballs, '@videojs/html'); + const packageJson = { + name: `videojs-shadcn-html-${config.styling}-validation`, + private: true, + version: '0.0.0', + packageManager: 'pnpm@11.17.0', + scripts: { + build: 'vite build', + check: 'tsc --noEmit', + start: 'vite preview', + }, + dependencies: { + '@videojs/html': htmlTarball, + }, + devDependencies: { + '@tailwindcss/vite': config.styling === 'tailwind' ? '4.3.3' : undefined, + tailwindcss: config.styling === 'tailwind' ? '4.3.3' : undefined, + typescript: '5.9.3', + vite: '8.2.2', + }, + }; + const components = { + $schema: 'https://ui.shadcn.com/schema.json', + style: 'new-york', + rsc: false, + tsx: true, + tailwind: { + config: '', + css: 'src/app.css', + baseColor: 'neutral', + cssVariables: true, + prefix: '', + }, + iconLibrary: 'lucide', + aliases: { + components: '@/components', + ui: '@/components/ui', + utils: '@/lib/utils', + lib: '@/lib', + hooks: '@/hooks', + }, + registries: { + '@videojs': `${config.address}/r/html${config.styling === 'css' ? '/css' : ''}/{name}.json`, + }, + }; + const tsconfig = { + compilerOptions: { + target: 'ES2022', + useDefineForClassFields: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + allowJs: false, + skipLibCheck: true, + strict: true, + noEmit: true, + module: 'ESNext', + moduleResolution: 'Bundler', + resolveJsonModule: true, + isolatedModules: true, + paths: { '@/*': ['./src/*'] }, + types: ['vite/client'], + }, + include: ['src', 'vite.config.ts'], + }; + const viteConfig = + config.styling === 'tailwind' + ? `import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss()] }); +` + : `import { defineConfig } from 'vite'; + +export default defineConfig({}); +`; + + await mkdir(resolve(config.root, 'src'), { recursive: true }); + await writeFile(resolve(config.root, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + await writeFile(resolve(config.root, 'components.json'), `${JSON.stringify(components, null, 2)}\n`); + await writeFile(resolve(config.root, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`); + await writeFile(resolve(config.root, 'pnpm-workspace.yaml'), workspaceConfig(overrides)); + await writeFile(resolve(config.root, 'vite.config.ts'), viteConfig); + await writeFile( + resolve(config.root, 'src/app.css'), + config.styling === 'tailwind' + ? '@import "tailwindcss";\n' + : 'html { color-scheme: dark; }\nbody { margin: 0; background: #111; color: #fff; font-family: sans-serif; }\n' + ); +} + +async function writeHtmlComposition(root: string): Promise { + const skin = await readFile(resolve(root, 'src/components/videojs/skins/video/skin.html'), 'utf8'); + const composition = skin + .replace('', + '' + ); + + await writeFile( + resolve(root, 'index.html'), + ` + + + + + Video.js registry validation + + +
+ ${composition} +
+ + + +` + ); + await writeFile( + resolve(root, 'src/main.ts'), + `import '@videojs/html/video/player'; +import '@videojs/html/media/hlsjs-video'; + +import './app.css'; +import './components/videojs/skins/video/skin'; +` + ); +} + +function requiredTarball(tarballs: ReadonlyMap, name: string): string { + const tarball = tarballs.get(name); + if (!tarball) throw new Error(`Missing packed artifact for ${name}.`); + + return tarball; +} + +function workspaceConfig(overrides: Readonly>): string { + const entries = Object.entries(overrides) + .map(([name, value]) => ` '${name}': '${value}'`) + .join('\n'); + + return `packages:\n - '.'\n\nallowBuilds:\n 'unrs-resolver@1.12.2': true\n\noverrides:\n${entries}\n`; +} + +async function writePlayer(root: string, styling: 'tailwind' | 'css'): Promise { + const skinProps = + styling === 'tailwind' ? 'className="aspect-video w-full"' : "style={{ aspectRatio: '16 / 9', width: '100%' }}"; + + await writeFile( + resolve(root, 'app/player.tsx'), + `'use client'; + +import { useMedia } from '@videojs/react'; +import { HlsJsVideo } from '@videojs/react/media/hlsjs-video'; +import { VideoPlayer } from '@videojs/react/video'; + +import { DefaultVideoSkin } from '@/components/videojs/skins/video/skin'; + +function MediaProbe() { + const media = useMedia(); + + return ; +} + +export function Player() { + return ( + + + + + + + ); +} +` + ); +} + +async function packVideojsPackages(directory: string): Promise> { + await mkdir(directory, { recursive: true }); + + const tarballs = new Map(); + + for (const name of videojsPackages) { + const manifest = JSON.parse(await readFile(resolve(workspaceDir, 'packages', name, 'package.json'), 'utf8')); + const result = await runCommand( + 'pnpm', + ['--dir', resolve(workspaceDir, 'packages', name), 'pack', '--pack-destination', directory, '--json'], + workspaceDir, + { npm_config_ignore_scripts: 'true' } + ); + const jsonStart = result.stdout.indexOf('{'); + const packed = JSON.parse(result.stdout.slice(jsonStart)); + const filename = Array.isArray(packed) ? packed[0]?.filename : packed.filename; + if (!isString(manifest.name) || !isString(filename)) throw new Error(`Could not pack @videojs/${name}.`); + + tarballs.set(manifest.name, `file:${filename}`); + } + + return tarballs; +} + +async function assertPackagePins(items: readonly RegistryItem[]): Promise { + const versions = new Map(); + + for (const name of registryPackages) { + const manifest = JSON.parse(await readFile(resolve(workspaceDir, 'packages', name, 'package.json'), 'utf8')); + + versions.set(manifest.name, manifest.version); + } + + for (const item of items) { + for (const dependency of item.dependencies ?? []) { + if (!dependency.startsWith('@videojs/')) continue; + + const separator = dependency.lastIndexOf('@'); + const name = dependency.slice(0, separator); + const version = dependency.slice(separator + 1); + const expected = versions.get(name); + + if (separator <= 0 || !expected || version !== expected) { + throw new Error( + `${item.name} must pin ${name || dependency} to its workspace artifact (${expected ?? 'unknown package'}).` + ); + } + } + } +} + +async function assertInstalled(root: string, paths: readonly string[]): Promise { + for (const path of paths) { + const source = await readFile(resolve(root, path), 'utf8').catch(() => undefined); + if (!source) throw new Error(`Shadcn did not install ${path}.`); + } +} + +async function add(root: string, names: readonly string[]): Promise { + await runShadcn(['add', ...names, '--cwd', root, '--yes', '--silent'], root); +} + +async function validateRuntime(root: string, styling: string): Promise { + const port = await availablePort(); + const child = startCommand('pnpm', ['start', '--hostname', '127.0.0.1', '--port', String(port)], root); + + try { + await waitForServer(`http://127.0.0.1:${port}`); + + const browser = await chromium.launch({ headless: true }); + + try { + const page = await browser.newPage(); + const errors: string[] = []; + + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(`http://127.0.0.1:${port}`, { waitUntil: 'domcontentloaded' }); + await page.locator('[data-testid="media-probe"][data-attached="true"]').waitFor({ state: 'attached' }); + await page.locator('video').waitFor(); + await page.locator('button').first().waitFor(); + + const skin = page.locator('.media-skin').first(); + const box = await skin.boundingBox(); + const display = await skin.evaluate((element) => getComputedStyle(element).display); + + if (!box || box.width < 100 || box.height < 50 || display === 'none') { + throw new Error(`${styling} player did not receive usable skin styles.`); + } + + if (errors.length > 0) { + throw new Error(`${styling} runtime emitted browser errors:\n${errors.join('\n')}`); + } + } finally { + await browser.close(); + } + } finally { + await stopCommand(child); + } +} + +async function sourceHashes(directory: string): Promise> { + const files = await walkFiles(directory); + + return Promise.all( + files.map(async (filename) => { + const source = await readFile(filename); + + return [relative(directory, filename), createHash('sha256').update(source).digest('hex')] as const; + }) + ); +} + +async function walkFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + const files = await Promise.all( + entries.map((entry) => { + const path = resolve(directory, entry.name); + + return entry.isDirectory() ? walkFiles(path) : [path]; + }) + ); + + return files.flat().sort(); +} + +async function assertIgnoredOutput(): Promise { + await Promise.all( + [sourceDir, hostedDir].map((path) => + runCommand('git', ['check-ignore', '--quiet', relative(workspaceDir, path)], workspaceDir) + ) + ); +} + +async function runShadcn( + args: readonly string[], + cwd: string +): Promise<{ readonly stdout: string; readonly stderr: string }> { + return runCommand(process.execPath, [shadcnBin, ...args], cwd); +} + +async function runCommand( + executable: string, + args: readonly string[], + cwd: string, + env: Readonly> = {} +): Promise<{ readonly stdout: string; readonly stderr: string }> { + return new Promise((resolvePromise, reject) => { + execFile( + executable, + [...args], + { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...env, + CI: '1', + FORCE_COLOR: '0', + NEXT_TELEMETRY_DISABLED: '1', + NO_COLOR: '1', + }, + maxBuffer: 100 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (!error) { + resolvePromise({ stdout, stderr }); + return; + } + + reject( + new Error([`${executable} ${args.join(' ')}`, stdout, stderr].filter(Boolean).join('\n'), { + cause: error, + }) + ); + } + ); + }); +} + +function startCommand(executable: string, args: readonly string[], cwd: string): ChildProcessWithoutNullStreams { + return spawn(executable, [...args], { + cwd, + env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, + stdio: 'pipe', + }); +} + +async function waitForServer(url: string): Promise { + for (let attempt = 0; attempt < 120; attempt++) { + const response = await fetch(url).catch(() => undefined); + if (response?.ok) return; + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + } + + throw new Error(`Timed out waiting for ${url}.`); +} + +async function stopCommand(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) return; + + child.kill('SIGTERM'); + await Promise.race([ + new Promise((resolvePromise) => child.once('exit', () => resolvePromise())), + new Promise((resolvePromise) => + setTimeout(() => { + child.kill('SIGKILL'); + resolvePromise(); + }, 5_000) + ), + ]); +} + +async function availablePort(): Promise { + const server = createServer(); + const address = await listen(server); + const port = Number(new URL(address).port); + + await close(server); + return port; +} + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + + const address = server.address(); + if (!address || isString(address)) throw new Error('Could not resolve the server address.'); + + return `http://127.0.0.1:${address.port}`; +} + +async function close(server: Server): Promise { + await new Promise((resolvePromise, reject) => { + server.close((error) => (error ? reject(error) : resolvePromise())); + }); +} diff --git a/packages/skins/shadcn/format.ts b/packages/skins/shadcn/format.ts index 18aacf5c67..d5ce15d51f 100644 --- a/packages/skins/shadcn/format.ts +++ b/packages/skins/shadcn/format.ts @@ -4,6 +4,21 @@ import type { Plugin } from 'vite'; const sourceFile = /\.(?:css|[cm]?[jt]sx?)$/; +/** Format one editable registry source file with the settings used by the hosted catalog. */ +export function formatRegistrySource(filename: string, source: string) { + return format(filename, source, { + arrowParens: 'always', + bracketSpacing: true, + jsdoc: true, + printWidth: 120, + semi: true, + singleQuote: !filename.endsWith('.css'), + sortImports: true, + tabWidth: 2, + trailingComma: 'es5', + }); +} + /** Format editable registry source before Shadcn embeds it in installable item JSON. */ export function formatRegistrySources(): Plugin { return { @@ -13,17 +28,7 @@ export function formatRegistrySources(): Plugin { Object.values(bundle).map(async (asset) => { if (asset.type !== 'asset' || !isString(asset.source) || !sourceFile.test(asset.fileName)) return; - const result = await format(asset.fileName, asset.source, { - arrowParens: 'always', - bracketSpacing: true, - jsdoc: true, - printWidth: 120, - semi: true, - singleQuote: !asset.fileName.endsWith('.css'), - sortImports: true, - tabWidth: 2, - trailingComma: 'es5', - }); + const result = await formatRegistrySource(asset.fileName, asset.source); if (result.errors.length > 0) { const messages = result.errors.map((error) => error.message).join('\n'); diff --git a/packages/skins/shadcn/vite.config.ts b/packages/skins/shadcn/vite.config.ts index b569effb9a..875734e07b 100644 --- a/packages/skins/shadcn/vite.config.ts +++ b/packages/skins/shadcn/vite.config.ts @@ -340,6 +340,7 @@ function utilsItem( title: 'Video.js Utilities', description: 'Resolves state-aware class names used by editable Video.js React components.', docs: 'Installed automatically with React components and composed with the project Shadcn `cn` utility.', + registryDependencies: ['utils'], meta: { role: 'support', framework: 'react', diff --git a/packages/skins/vite.config.ts b/packages/skins/vite.config.ts index 2a0d7d183d..cc0e05467e 100644 --- a/packages/skins/vite.config.ts +++ b/packages/skins/vite.config.ts @@ -66,7 +66,15 @@ export default defineConfig({ input: ['dist/registry/source/r/html/css/**'], output: ['dist/registry/r/html/css/**'], }, - 'test:ci': packageTestTask('pnpm run test:types && vp test run'), + 'validate:shadcn': { + command: 'node --import tsx scripts/validate-shadcn-registry.ts', + dependsOn: ['build:shadcn', '@videojs/react#build'], + cache: false, + }, + 'test:ci': { + ...packageTestTask('pnpm run test:types && vp test run'), + dependsOn: ['build', 'validate:shadcn'], + }, }, }, test: { diff --git a/packages/skins/vjsc/tests/shadcn.test.ts b/packages/skins/vjsc/tests/shadcn.test.ts index 9f4ebf1285..5bc3f468c9 100644 --- a/packages/skins/vjsc/tests/shadcn.test.ts +++ b/packages/skins/vjsc/tests/shadcn.test.ts @@ -138,7 +138,8 @@ describe('Skins Shadcn registry', () => { const helperSource = registrySource(assets, 'r/react/support', helper, '/resolve-class-name.ts'); expect(helper.files[0]?.target).toBe('@components/videojs/lib/resolve-class-name.ts'); - expect(helper.dependencies).toBeUndefined(); + expect(helper.dependencies).toEqual(['clsx']); + expect(helper.registryDependencies).toEqual(['utils']); expect(helperSource).toContain(`export { cn } from '@/lib/utils';`); }, 120_000); @@ -161,7 +162,6 @@ describe('Skins Shadcn registry', () => { type BuiltItem = Omit & { files: Array<{ type: string; path: string; target?: string | undefined }>; - meta?: Record | undefined; }; function catalogItems(assets: ReadonlyMap, root: string): BuiltItem[] { @@ -172,6 +172,7 @@ function assetJson(assets: ReadonlyMap, fileName: string) const source = assets.get(fileName); if (!source) throw new Error(`Missing registry asset: ${fileName}`); + // SAFETY: registry output is generated from and validated against the requested Shadcn schema type. return JSON.parse(source) as Value; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6167fe8626..e45d68485b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@lit/context': specifier: ^1.1.0 version: 1.1.6 + '@playwright/test': + specifier: ^1.52.0 + version: 1.59.1 '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(typescript@6.0.2)(unrun@0.2.39)(yaml@2.9.0))