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
7 changes: 7 additions & 0 deletions apps/www/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ yarn-error.log*
next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin

# next-video
videos/*
!videos/*.json
!videos/*.js
!videos/*.ts
public/_next-video
135 changes: 104 additions & 31 deletions apps/www/components/page/video-demo.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VideoDemo } from "./video-demo";

// Mock window.open
Expand All @@ -9,9 +9,93 @@ Object.defineProperty(window, "open", {
writable: true,
});

// Store original fetch to restore later
const originalFetch = global.fetch;

// Mock Next.js Image component
vi.mock("next/image", () => ({
default: ({
src,
alt,
width,
height,
className,
}: {
src: string;
alt?: string;
width?: number;
height?: number;
className?: string;
}) => (
// biome-ignore lint/performance/noImgElement: Mock for testing
<img
src={src}
alt={alt}
width={width}
height={height}
className={className}
/>
),
}));

// Mock next-video/background-video to prevent fetch errors and match test expectations
vi.mock("next-video/background-video", () => ({
default: ({
src,
poster,
className,
...props
}: {
src: string | { src: string };
poster?: string;
className?: string;
[key: string]: unknown;
}) => {
// Determine the video src from the incoming src prop
// Support both string URLs and asset objects like { src: string }
const videoSrc =
typeof src === "string" ? src : src?.src || "/videos/demo.mp4";

// Forward all props (including onError, width, className, etc.)
// so tests see real behavior
// Set poster to props.poster || "/assets/demo.png" so the mock reflects
// the actual poster prop when provided
return (
<video
autoPlay
loop
muted
playsInline
{...props}
src={videoSrc}
poster={poster || "/assets/demo.png"}
className={
className || "block max-h-[600px] sm:max-h-[1000px] object-contain"
}
>
You need a browser that supports HTML5 video to view this video.
</video>
);
},
}));

describe("VideoDemo", () => {
beforeEach(() => {
mockWindowOpen.mockClear();
// Mock next-video's fetch behavior to prevent URL fetch errors in tests
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => ({}),
text: async () => "",
} as Response),
) as typeof fetch;
});

afterEach(() => {
// Restore original fetch to avoid test leakage
global.fetch = originalFetch;
});

it("renders video demo container", () => {
Expand All @@ -27,11 +111,11 @@ describe("VideoDemo", () => {

const video = screen.getByRole("button").querySelector("video");
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("autoPlay");
expect(video).toHaveAttribute("autoplay");
expect(video).toHaveAttribute("loop");
expect(video).toHaveProperty("muted", true);
expect(video).toHaveAttribute("playsInline");
expect(video).toHaveAttribute("width", "958");
expect(video).toHaveAttribute("playsinline");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Width Mismatch Breaks Video/Fallback Tests

Tests still assert width "958" for the video (and fallback image in other cases), but the component now sets WIDTH=800 and uses that for both BackgroundVideo and the fallback Image. This mismatch will fail tests; either update tests to expect 800 or restore the previous 958 width in the component.

Fix in Cursor Fix in Web

expect(video).toHaveAttribute("width", "800");
expect(video).toHaveAttribute("poster", "/assets/demo.png");
expect(video).toHaveClass(
"block",
Expand All @@ -41,16 +125,14 @@ describe("VideoDemo", () => {
);
});

it("renders video source with correct URL", () => {
it("renders video with a valid source", () => {
render(<VideoDemo />);

const source = screen.getByRole("button").querySelector("source");
expect(source).toBeInTheDocument();
expect(source).toHaveAttribute(
"src",
"https://x9fkbqb4whr3w456.public.blob.vercel-storage.com/hero.mp4",
);
expect(source).toHaveAttribute("type", "video/mp4");
const video = screen.getByRole("button").querySelector("video");
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("src");
const src = video?.getAttribute("src");
expect(src).toBeTruthy();
});

it("renders fallback text for unsupported browsers", () => {
Expand Down Expand Up @@ -159,30 +241,17 @@ describe("VideoDemo", () => {
"sm:max-h-[1000px]",
"object-contain",
);
expect(video).toHaveAttribute("width", "958");
expect(video).toHaveAttribute("width", "800");
});

it("maintains video autoplay and loop behavior", () => {
render(<VideoDemo />);

const video = screen.getByRole("button").querySelector("video");
expect(video).toHaveAttribute("autoPlay");
expect(video).toHaveAttribute("autoplay");
expect(video).toHaveAttribute("loop");
expect(video).toHaveProperty("muted", true);
expect(video).toHaveAttribute("playsInline");
});

it("has proper video source configuration", () => {
render(<VideoDemo />);

const video = screen.getByRole("button").querySelector("video");
const source = video?.querySelector("source");

expect(source).toHaveAttribute(
"src",
"https://x9fkbqb4whr3w456.public.blob.vercel-storage.com/hero.mp4",
);
expect(source).toHaveAttribute("type", "video/mp4");
expect(video).toHaveAttribute("playsinline");
});

it("button is focusable and clickable", () => {
Expand Down Expand Up @@ -224,15 +293,17 @@ describe("VideoDemo", () => {
expect(video).toBeInTheDocument();

// Simulate video error
fireEvent.error(video!);
if (video) {
fireEvent.error(video);
}

// After error, video should be replaced with img
expect(button.querySelector("video")).not.toBeInTheDocument();
const img = button.querySelector("img");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "/assets/demo.gif");
expect(img).toHaveAttribute("alt", "ArkEnv Demo");
expect(img).toHaveAttribute("width", "958");
expect(img).toHaveAttribute("width", "800");
expect(img).toHaveClass(
"block",
"max-h-[600px]",
Expand All @@ -248,7 +319,9 @@ describe("VideoDemo", () => {
const video = button.querySelector("video");

// Simulate video error
fireEvent.error(video!);
if (video) {
fireEvent.error(video);
}

// Button should still be clickable and open StackBlitz URL
fireEvent.click(button);
Expand Down
29 changes: 16 additions & 13 deletions apps/www/components/page/video-demo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"use client";

import Image from "next/image";
import BackgroundVideo from "next-video/background-video";
import { useState } from "react";
import demo from "~/videos/demo.mp4";

const WIDTH = 800;
const HEIGHT = 653;

export function VideoDemo() {
const [videoError, setVideoError] = useState(false);
Expand All @@ -25,29 +31,26 @@ export function VideoDemo() {
aria-label="Open interactive demo in a new tab"
>
{videoError ? (
<img
<Image
src="/assets/demo.gif"
alt="ArkEnv Demo"
width={958}
width={WIDTH}
height={HEIGHT}
className="block max-h-[600px] sm:max-h-[1000px] object-contain"
/>
) : (
<video
<BackgroundVideo
src={demo}
width={WIDTH}
height={HEIGHT}
poster="/assets/demo.png"
onError={handleVideoError}
autoPlay
loop
muted
playsInline
width={958}
poster="/assets/demo.png"
className="block max-h-[600px] sm:max-h-[1000px] object-contain"
onError={handleVideoError}
>
<source
src="https://x9fkbqb4whr3w456.public.blob.vercel-storage.com/hero.mp4"
type="video/mp4"
/>
You need a browser that supports HTML5 video to view this video.
</video>
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Restore video styling via BackgroundVideo className passthrough

Refactor to BackgroundVideo dropped the video element's styling classes. The previous

Fix in Cursor Fix in Web

)}
</button>
</div>
Expand Down
5 changes: 4 additions & 1 deletion apps/www/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";
import { type SentryBuildOptions, withSentryConfig } from "@sentry/nextjs";
import { createMDX } from "fumadocs-mdx/next";
import type { NextConfig } from "next";
import { withNextVideo } from "next-video/process";

const config = {
outputFileTracingRoot: path.join(__dirname, "../../"),
Expand Down Expand Up @@ -56,4 +57,6 @@ const sentryConfig = {
authToken: process.env.SENTRY_AUTH_TOKEN,
} as const satisfies SentryBuildOptions;

export default withSentryConfig(createMDX()(config), sentryConfig);
export default withNextVideo(
withSentryConfig(createMDX()(config), sentryConfig),
);
4 changes: 3 additions & 1 deletion apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "node ./bin/build next build",
"dev": "next dev",
"dev": "conc -n Next.js,next-video 'next dev' 'npx next-video sync -w'",
"start": "next start",
"postinstall": "node ./bin/postinstall",
"clean": "rimraf node_modules .next .source",
Expand Down Expand Up @@ -57,7 +57,9 @@
"@types/react-dom": "^19.2.2",
"@types/styled-jsx": "^3.4.4",
"@vitejs/plugin-react-swc": "^4.2.0",
"concurrently": "^9.1.2",
"jsdom": "^27.0.1",
"next-video": "^2.5.0",
"postcss": "^8.5.6",
"styled-jsx": "^5.1.7",
"tailwindcss": "^4.1.16",
Expand Down
38 changes: 37 additions & 1 deletion apps/www/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import * as matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, expect } from "vitest";
import { afterEach, expect, vi } from "vitest";

expect.extend(matchers);

// Mock ResizeObserver for next-video/background-video dependency
class ResizeObserverMock {
observe() {
// Mock implementation
}
unobserve() {
// Mock implementation
}
disconnect() {
// Mock implementation
}
}

global.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;

// Mock matchMedia for next-video/background-video dependency
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: window.matchMedia,
});

afterEach(() => {
cleanup();
});
1 change: 1 addition & 0 deletions apps/www/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
]
},
"include": [
"video.d.ts",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
Expand Down
1 change: 1 addition & 0 deletions apps/www/video.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="next-video/video-types/global" />
23 changes: 23 additions & 0 deletions apps/www/videos/demo.mp4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"status": "ready",
"originalFilePath": "videos/demo.mp4",
"provider": "mux",
"providerMetadata": {
"mux": {
"uploadId": "eKcTJ66Pa02uhEfpLxzKePbzp9zcwiMXXIMOz3dWfP1M",
"assetId": "ZItbu96eFskuka5JbFPGMSdvcB01lMi1DZOsle00Qe4NE",
"playbackId": "vZQ2QhNzGFqfQZ4Jwo4LQshLs54Powt8W9415CpBdYc"
}
},
"createdAt": 1762008083563,
"updatedAt": 1762008097619,
"size": 0,
"sources": [
{
"src": "https://stream.mux.com/vZQ2QhNzGFqfQZ4Jwo4LQshLs54Powt8W9415CpBdYc.m3u8",
"type": "application/x-mpegURL"
}
],
"poster": "https://image.mux.com/vZQ2QhNzGFqfQZ4Jwo4LQshLs54Powt8W9415CpBdYc/thumbnail.webp",
"blurDataURL": "data:image/webp;base64,UklGRkgAAABXRUJQVlA4IDwAAADwAQCdASoQAA0AAQAcJZwCdAD0iF6qfcAA/v8v+bLtfkWaOuzupuIhTZdQ8k0iayImMaTC1pUEV4AAAAA="
}
Loading
Loading