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
32 changes: 32 additions & 0 deletions app/components/StreamViewer/ConnectionStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react"
import { ConnectionStatus } from "./ConnectionStatus"

describe("ConnectionStatus", () => {
it("renders the connecting status correctly", () => {
const { container } = render(<ConnectionStatus status="connecting" />)
expect(screen.getByText("connecting")).toBeInTheDocument()
const dot = container.querySelector(".rounded-full")
expect(dot).toHaveClass("bg-yellow-500")
})

it("renders the connected status correctly", () => {
const { container } = render(<ConnectionStatus status="connected" />)
expect(screen.getByText("connected")).toBeInTheDocument()
const dot = container.querySelector(".rounded-full")
expect(dot).toHaveClass("bg-green-500")
})

it("renders the disconnected status correctly", () => {
const { container } = render(<ConnectionStatus status="disconnected" />)
expect(screen.getByText("disconnected")).toBeInTheDocument()
const dot = container.querySelector(".rounded-full")
expect(dot).toHaveClass("bg-gray-500")
})

it("renders the error status correctly (error state test)", () => {
const { container } = render(<ConnectionStatus status="error" />)
expect(screen.getByText("error")).toBeInTheDocument()
const dot = container.querySelector(".rounded-full")
expect(dot).toHaveClass("bg-red-500")
})
})
40 changes: 40 additions & 0 deletions app/components/StreamViewer/StreamFeed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react"
import { StreamFeed } from "./StreamFeed"

describe("StreamFeed", () => {
it("renders empty state when no events are provided (empty/error state test)", () => {
render(<StreamFeed events={[]} />)
expect(screen.getByText("No events received yet.")).toBeInTheDocument()
})

it("renders a list of stream events correctly (rendering test)", () => {
const events = [
{
id: "1",
type: "info",
timestamp: "2026-06-18T12:00:00.000Z",
message: "Stream started",
},
{
id: "2",
type: "warning",
timestamp: "2026-06-18T12:05:00.000Z",
message: "High latency detected",
},
]

render(<StreamFeed events={events} />)

expect(screen.getByText("info")).toBeInTheDocument()
expect(screen.getByText("Stream started")).toBeInTheDocument()

expect(screen.getByText("warning")).toBeInTheDocument()
expect(screen.getByText("High latency detected")).toBeInTheDocument()

// Verify times are rendered
const time1 = new Date("2026-06-18T12:00:00.000Z").toLocaleTimeString()
const time2 = new Date("2026-06-18T12:05:00.000Z").toLocaleTimeString()
expect(screen.getByText(time1)).toBeInTheDocument()
expect(screen.getByText(time2)).toBeInTheDocument()
})
})
63 changes: 63 additions & 0 deletions app/components/StreamViewer/StreamViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react"
import { StreamViewer } from "./StreamViewer"
import { useStreamSocket } from "../../hooks/useStreamSocket"

// Mock the useStreamSocket hook
jest.mock("../../hooks/useStreamSocket")

const mockUseStreamSocket = useStreamSocket as jest.MockedFunction<
typeof useStreamSocket
>

describe("StreamViewer", () => {
beforeEach(() => {
jest.clearAllMocks()
})

it("renders connecting state and empty feed (rendering test)", () => {
mockUseStreamSocket.mockReturnValue({
status: "connecting",
events: [],
})

render(<StreamViewer socketUrl="ws://localhost:3001" />)

expect(screen.getByText("Live Stream Feed")).toBeInTheDocument()
expect(screen.getByText("connecting")).toBeInTheDocument()
expect(screen.getByText("No events received yet.")).toBeInTheDocument()
expect(mockUseStreamSocket).toHaveBeenCalledWith("ws://localhost:3001")
})

it("renders connected state with events (interaction / data rendering test)", () => {
const mockEvents = [
{
id: "event-1",
type: "click",
timestamp: "2026-06-18T12:00:00.000Z",
message: "User clicked submit",
},
]
mockUseStreamSocket.mockReturnValue({
status: "connected",
events: mockEvents,
})

render(<StreamViewer socketUrl="ws://localhost:3001" />)

expect(screen.getByText("connected")).toBeInTheDocument()
expect(screen.getByText("click")).toBeInTheDocument()
expect(screen.getByText("User clicked submit")).toBeInTheDocument()
})

it("renders error state (error state test)", () => {
mockUseStreamSocket.mockReturnValue({
status: "error",
events: [],
})

render(<StreamViewer socketUrl="ws://localhost:3001" />)

expect(screen.getByText("error")).toBeInTheDocument()
expect(screen.getByText("No events received yet.")).toBeInTheDocument()
})
})
184 changes: 184 additions & 0 deletions app/components/streams/embed-snippet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { render, screen, act, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { EmbedSnippet } from "./embed-snippet"

type MutableNavigator = Omit<Navigator, "clipboard"> & { clipboard?: unknown }

describe("EmbedSnippet", () => {
const mockWriteText = jest.fn()
let originalClipboardDescriptor: PropertyDescriptor | undefined

beforeAll(() => {
originalClipboardDescriptor = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"clipboard",
)

const mockClipboard = {
writeText: mockWriteText,
}

// Define on prototype
Object.defineProperty(Navigator.prototype, "clipboard", {
value: mockClipboard,
configurable: true,
writable: true,
})

// Define on window.navigator
try {
Object.defineProperty(window.navigator, "clipboard", {
value: mockClipboard,
configurable: true,
writable: true,
})
} catch (e) {}

// Define on global.navigator
try {
Object.defineProperty(navigator, "clipboard", {
value: mockClipboard,
configurable: true,
writable: true,
})
} catch (e) {}
})

afterAll(() => {
if (originalClipboardDescriptor) {
Object.defineProperty(
Navigator.prototype,
"clipboard",
originalClipboardDescriptor,
)
} else {
delete (Navigator.prototype as unknown as MutableNavigator).clipboard
}

try {
delete (window.navigator as unknown as MutableNavigator).clipboard
} catch (e) {}

try {
delete (navigator as unknown as MutableNavigator).clipboard
} catch (e) {}
})




beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
mockWriteText.mockResolvedValue(undefined)
})

afterEach(() => {
jest.useRealTimers()
})

it("renders the iframe embed code with publicId (rendering test)", () => {
render(<EmbedSnippet publicId="stream-123" />)

expect(screen.getByText("Embed snippet")).toBeInTheDocument()
const codeContainer = screen.getByLabelText("iframe embed code")
expect(codeContainer).toHaveTextContent(
'<iframe src="https://xstreamroll.example.com/embed/stream-123"',
)
expect(codeContainer).toHaveTextContent('width="640" height="360"')
})

it("supports custom width, height, and viewerBase", () => {
render(
<EmbedSnippet
publicId="stream-abc"
viewerBase="https://myviewer.net/"
width={800}
height={450}
/>,
)

const codeContainer = screen.getByLabelText("iframe embed code")
expect(codeContainer).toHaveTextContent(
'<iframe src="https://myviewer.net/embed/stream-abc"',
)
expect(codeContainer).toHaveTextContent('width="800" height="450"')
})

it("copies the code to clipboard and displays Copied state (interaction test)", async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })

Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
writable: true,
})
Object.defineProperty(window.navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
writable: true,
})

render(<EmbedSnippet publicId="stream-123" />)



const copyBtn = screen.getByRole("button", { name: "Copy embed snippet" })
await user.click(copyBtn)

const expectedSnippet =
'<iframe src="https://xstreamroll.example.com/embed/stream-123"\n' +
' width="640" height="360"\n' +
' frameborder="0"\n' +
' allow="autoplay; encrypted-media; picture-in-picture"\n' +
' allowfullscreen></iframe>'

await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith(expectedSnippet)
expect(
screen.getByRole("button", { name: "Copied" }),
).toBeInTheDocument()
})

// Fast-forward 1800ms
act(() => {
jest.advanceTimersByTime(1800)
})

expect(
screen.getByRole("button", { name: "Copy embed snippet" }),
).toBeInTheDocument()
})

it("throws error in dev if publicId is missing (validation test)", () => {
// Suppress console.error output to avoid test pollution
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {})

expect(() => {
render(<EmbedSnippet publicId="" />)
}).toThrow("EmbedSnippet: publicId is required")

consoleSpy.mockRestore()
})

it("throws error in dev if publicId looks like a secret (validation / security test)", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {})

// Containing dot (like JWT or token)
expect(() => {
render(<EmbedSnippet publicId="stream.jwt.token" />)
}).toThrow("EmbedSnippet: publicId looks like a secret token")

// Too long (length > 64)
const longId = "a".repeat(65)
expect(() => {
render(<EmbedSnippet publicId={longId} />)
}).toThrow("EmbedSnippet: publicId looks like a secret token")

consoleSpy.mockRestore()
})
})
53 changes: 53 additions & 0 deletions app/components/streams/stream-tag-chips.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { StreamTagChips } from "./stream-tag-chips"
import { Tag } from "@/lib/api/tags"

describe("StreamTagChips", () => {
const mockTags: Tag[] = [
{ id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" },
{ id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" },
]

it("renders empty label when tags list is empty (empty state test)", () => {
render(<StreamTagChips tags={[]} />)
expect(screen.getByText("No tags attached.")).toBeInTheDocument()
})

it("renders custom empty label when provided", () => {
render(<StreamTagChips tags={[]} emptyLabel="Empty list" />)
expect(screen.getByText("Empty list")).toBeInTheDocument()
})

it("renders tags badges in read-only mode (rendering test)", () => {
render(<StreamTagChips tags={mockTags} />)
expect(screen.getByText("Gaming")).toBeInTheDocument()
expect(screen.getByText("Music")).toBeInTheDocument()
// No remove buttons should be present
expect(screen.queryByRole("button")).not.toBeInTheDocument()
})

it("renders tags with remove buttons and triggers onRemove on click (interaction test)", async () => {
const user = userEvent.setup()
const handleRemove = jest.fn()

render(<StreamTagChips tags={mockTags} onRemove={handleRemove} />)

expect(screen.getByText("Gaming")).toBeInTheDocument()
expect(screen.getByText("Music")).toBeInTheDocument()

const removeButtons = screen.getAllByRole("button")
expect(removeButtons).toHaveLength(2)

expect(
screen.getByRole("button", { name: "Remove tag Gaming" }),
).toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Remove tag Music" }),
).toBeInTheDocument()

await user.click(removeButtons[0])
expect(handleRemove).toHaveBeenCalledTimes(1)
expect(handleRemove).toHaveBeenCalledWith(mockTags[0])
})
})
Loading
Loading