diff --git a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx
index 17511e18ad..13fdddbd8e 100644
--- a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx
+++ b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx
@@ -1,68 +1,98 @@
import { render, screen } from '@testing-library/react'
-import { usePathname } from 'next/navigation'
-import BreadCrumbs from 'components/BreadCrumbs'
+import BreadCrumbRenderer from 'components/BreadCrumbs'
import '@testing-library/jest-dom'
-jest.mock('next/navigation', () => ({
- usePathname: jest.fn(),
-}))
+describe('BreadCrumbRenderer', () => {
+ const mockItems = [
+ { title: 'Home', path: '/' },
+ { title: 'Projects', path: '/projects' },
+ { title: 'OWASP ZAP', path: '/projects/zap' },
+ ]
-describe('BreadCrumb', () => {
- afterEach(() => {
- jest.clearAllMocks()
+ test('renders all breadcrumb items', () => {
+ render()
+
+ expect(screen.getByText('Home')).toBeInTheDocument()
+ expect(screen.getByText('Projects')).toBeInTheDocument()
+ expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
})
- test('does not render on root path "/"', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/')
+ test('renders navigation element with correct aria-label', () => {
+ render()
- render()
- expect(screen.queryByText('Home')).not.toBeInTheDocument()
+ const nav = screen.getByRole('navigation')
+ expect(nav).toHaveAttribute('aria-label', 'breadcrumb')
})
- test('renders breadcrumb with multiple segments', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile')
+ test('renders clickable links for non-last items', () => {
+ render()
- render()
+ const homeLink = screen.getByText('Home').closest('a')
+ const projectsLink = screen.getByText('Projects').closest('a')
- expect(screen.getByText('Home')).toBeInTheDocument()
- expect(screen.getByText('Dashboard')).toBeInTheDocument()
- expect(screen.getByText('Users')).toBeInTheDocument()
- expect(screen.getByText('Profile')).toBeInTheDocument()
+ expect(homeLink).toHaveAttribute('href', '/')
+ expect(projectsLink).toHaveAttribute('href', '/projects')
})
- test('disables the last segment (non-clickable)', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/settings/account')
+ test('disables the last item (non-clickable)', () => {
+ render()
+
+ const lastItem = screen.getByText('OWASP ZAP')
+ expect(lastItem).not.toHaveAttribute('href')
+ expect(lastItem.tagName).toBe('SPAN')
+ })
- render()
+ test('applies hover styles to clickable links', () => {
+ render()
- const lastSegment = screen.getByText('Account')
- expect(lastSegment).toBeInTheDocument()
- expect(lastSegment).not.toHaveAttribute('href')
+ const homeLink = screen.getByText('Home').closest('a')
+ expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline')
})
- test('links have correct href attributes', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile')
+ test('applies disabled styling to last breadcrumb', () => {
+ render()
- render()
+ const lastItem = screen.getByText('OWASP ZAP')
+ expect(lastItem).toHaveClass('cursor-default', 'font-semibold')
+ })
- const homeLink = screen.getByText('Home').closest('a')
- const dashboardLink = screen.getByText('Dashboard').closest('a')
- const usersLink = screen.getByText('Users').closest('a')
+ test('renders chevron separators between items', () => {
+ const { container } = render()
- expect(homeLink).toHaveAttribute('href', '/')
- expect(dashboardLink).toHaveAttribute('href', '/dashboard')
- expect(usersLink).toHaveAttribute('href', '/dashboard/users')
+ const separators = container.querySelectorAll('[data-slot="separator"]')
+ expect(separators).toHaveLength(2)
})
- test('links have hover styles', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users')
+ test('handles single item (home only)', () => {
+ const singleItem = [{ title: 'Home', path: '/' }]
+ render()
- render()
+ expect(screen.getByText('Home')).toBeInTheDocument()
+ const separators = screen.queryByRole('separator')
+ expect(separators).not.toBeInTheDocument()
+ })
+
+ test('handles empty items array', () => {
+ const { container } = render()
+
+ const breadcrumbList = container.querySelector('[data-slot="list"]')
+ expect(breadcrumbList?.children).toHaveLength(0)
+ })
+
+ test('applies correct wrapper styling', () => {
+ const { container } = render()
+
+ const wrapper = container.querySelector('.mt-16')
+ expect(wrapper).toHaveClass('w-full', 'pt-4')
+ })
+
+ test('links have correct href attributes', () => {
+ render()
const homeLink = screen.getByText('Home').closest('a')
- const dashboardLink = screen.getByText('Dashboard').closest('a')
+ const projectsLink = screen.getByText('Projects').closest('a')
- expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline')
- expect(dashboardLink).toHaveClass('hover:text-blue-700', 'hover:underline')
+ expect(homeLink).toHaveAttribute('href', '/')
+ expect(projectsLink).toHaveAttribute('href', '/projects')
})
})
diff --git a/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx
new file mode 100644
index 0000000000..8327b4a86a
--- /dev/null
+++ b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext'
+import { usePathname } from 'next/navigation'
+import React, { useEffect } from 'react'
+import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper'
+
+jest.mock('next/navigation', () => ({
+ usePathname: jest.fn(),
+}))
+
+// Wrapper with BreadcrumbRoot for context tests
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+// Helper component to register a breadcrumb before rendering BreadCrumbsWrapper
+function BreadCrumbsWrapperWithTitle({ path, title }: Readonly<{ path: string; title: string }>) {
+ useEffect(() => {
+ const unregister = registerBreadcrumb({ path, title })
+ return unregister
+ }, [path, title])
+ return
+}
+
+describe('BreadCrumbsWrapper', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Route Detection - Should Hide', () => {
+ test('returns null on home page', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/')
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Route Detection - Should Render', () => {
+ test('renders for routes with registered breadcrumbs', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects')
+
+ render(, { wrapper })
+ expect(screen.getByText('Home')).toBeInTheDocument()
+ expect(screen.getByText('Projects')).toBeInTheDocument()
+ })
+
+ test('uses registered title', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project-1')
+
+ render(
+ ,
+ { wrapper }
+ )
+ // Should show registered title instead of URL slug
+ expect(screen.getByText('Security Scanner Tool')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/components/PageLayout.test.tsx b/frontend/__tests__/unit/components/PageLayout.test.tsx
new file mode 100644
index 0000000000..5a12bc6400
--- /dev/null
+++ b/frontend/__tests__/unit/components/PageLayout.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { BreadcrumbRoot } from 'contexts/BreadcrumbContext'
+import { usePathname } from 'next/navigation'
+import React from 'react'
+import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper'
+import PageLayout from 'components/PageLayout'
+
+jest.mock('next/navigation', () => ({
+ usePathname: jest.fn(),
+}))
+
+// Helper to wrap components with BreadcrumbRoot and render with BreadCrumbsWrapper
+const renderWithProvider = (ui: React.ReactElement) => {
+ return render(
+
+
+ {ui}
+
+ )
+}
+
+describe('PageLayout', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders children components', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects/zap')
+
+ renderWithProvider(
+
+ Child Content
+
+ )
+
+ expect(screen.getByText('Child Content')).toBeInTheDocument()
+ })
+
+ describe('Title Prop Handling', () => {
+ test('displays title in breadcrumbs', async () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects/zap')
+
+ renderWithProvider(
+
+ Content
+
+ )
+
+ // Wait for effect to run
+ await screen.findByText('OWASP ZAP')
+ expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
+ })
+
+ test('handles explicit path prop', async () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects/zap/details')
+
+ renderWithProvider(
+
+ Content
+
+ )
+
+ await screen.findByText('OWASP ZAP')
+ expect(screen.getByText('OWASP ZAP')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
new file mode 100644
index 0000000000..e24e0bb50b
--- /dev/null
+++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
@@ -0,0 +1,115 @@
+import { renderHook, act } from '@testing-library/react'
+import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext'
+import { useBreadcrumbs } from 'hooks/useBreadcrumbs'
+import { usePathname } from 'next/navigation'
+import React from 'react'
+
+jest.mock('next/navigation', () => ({
+ usePathname: jest.fn(),
+}))
+
+// Helper wrapper with BreadcrumbRoot
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('useBreadcrumbs', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Hybrid Pattern (registered + auto-generated)', () => {
+ test('returns Home for root path', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/')
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ expect(result.current).toEqual([{ title: 'Home', path: '/' }])
+ })
+
+ test('auto-generates breadcrumbs from URL when no items registered', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/members')
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ expect(result.current).toEqual([
+ { title: 'Home', path: '/' },
+ { title: 'Members', path: '/members' },
+ ])
+ })
+
+ test('uses registered title when available', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project')
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ let unregister: () => void
+ act(() => {
+ unregister = registerBreadcrumb({ title: 'Test Project', path: '/projects/test-project' })
+ })
+
+ expect(result.current).toEqual([
+ { title: 'Home', path: '/' },
+ { title: 'Projects', path: '/projects' },
+ { title: 'Test Project', path: '/projects/test-project' },
+ ])
+
+ act(() => {
+ unregister()
+ })
+ })
+
+ test('merges registered and auto-generated items', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/organizations/test-org/repositories/test-repo')
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ const unregisterFns: (() => void)[] = []
+ act(() => {
+ unregisterFns.push(
+ registerBreadcrumb({ title: 'Test Organization', path: '/organizations/test-org' }),
+ registerBreadcrumb({
+ title: 'Test Repository',
+ path: '/organizations/test-org/repositories/test-repo',
+ })
+ )
+ })
+
+ // Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS)
+ expect(result.current).toEqual([
+ { title: 'Home', path: '/' },
+ { title: 'Organizations', path: '/organizations' },
+ { title: 'Test Organization', path: '/organizations/test-org' },
+ { title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' },
+ ])
+
+ act(() => {
+ for (const fn of unregisterFns) {
+ fn()
+ }
+ })
+ })
+ })
+
+ describe('Edge cases', () => {
+ test('handles null pathname', () => {
+ ;(usePathname as jest.Mock).mockReturnValue(null)
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ expect(result.current).toEqual([{ title: 'Home', path: '/' }])
+ })
+
+ test('formats hyphenated URL segments', () => {
+ ;(usePathname as jest.Mock).mockReturnValue('/test-page/test-section')
+
+ const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
+
+ expect(result.current).toEqual([
+ { title: 'Home', path: '/' },
+ { title: 'Test Page', path: '/test-page' },
+ { title: 'Test Section', path: '/test-page/test-section' },
+ ])
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx
index 978f55026a..96da372687 100644
--- a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx
+++ b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx
@@ -28,6 +28,7 @@ jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: jest.fn(() => mockRouter),
useParams: () => ({ chapterKey: 'test-chapter' }),
+ usePathname: jest.fn(() => '/chapters/test-chapter'),
}))
describe('chapterDetailsPage Component', () => {
diff --git a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
index e27fff0286..a9c776505f 100644
--- a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
+++ b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
@@ -22,6 +22,7 @@ jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: jest.fn(() => mockRouter),
useParams: () => ({ committeeKey: 'test-committee' }),
+ usePathname: jest.fn(() => '/committees/test-committee'),
}))
describe('CommitteeDetailsPage Component', () => {
@@ -52,7 +53,7 @@ describe('CommitteeDetailsPage Component', () => {
test('renders committee data correctly', async () => {
render()
await waitFor(() => {
- expect(screen.getByText('Test Committee')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Test Committee' })).toBeInTheDocument()
})
expect(screen.getByText('This is a test committee summary.')).toBeInTheDocument()
expect(screen.getByText('Leader 1')).toBeInTheDocument()
diff --git a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx
index 1b1281fe65..1a4e27d98b 100644
--- a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx
+++ b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx
@@ -28,6 +28,7 @@ jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: jest.fn(() => mockRouter),
useParams: () => ({ repositoryKey: 'test-org' }),
+ usePathname: jest.fn(() => '/organizations/test-org'),
}))
const mockError = {
@@ -70,7 +71,7 @@ describe('OrganizationDetailsPage', () => {
render()
await waitFor(() => {
- expect(screen.getByText('Test Organization')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Test Organization' })).toBeInTheDocument()
})
expect(screen.getByText('@test-org')).toBeInTheDocument()
diff --git a/frontend/__tests__/unit/utils/breadcrumb.test.ts b/frontend/__tests__/unit/utils/breadcrumb.test.ts
new file mode 100644
index 0000000000..f944ee43f7
--- /dev/null
+++ b/frontend/__tests__/unit/utils/breadcrumb.test.ts
@@ -0,0 +1,37 @@
+import { formatBreadcrumbTitle } from 'utils/breadcrumb'
+
+describe('formatBreadcrumbTitle', () => {
+ it('capitalizes single word', () => {
+ expect(formatBreadcrumbTitle('projects')).toBe('Projects')
+ })
+
+ it('splits hyphenated slug and capitalizes each word', () => {
+ expect(formatBreadcrumbTitle('test-org-repo')).toBe('Test Org Repo')
+ })
+
+ it('handles multiple hyphens', () => {
+ expect(formatBreadcrumbTitle('test-multi-word-slug-example')).toBe(
+ 'Test Multi Word Slug Example'
+ )
+ })
+
+ it('preserves uppercase words', () => {
+ expect(formatBreadcrumbTitle('OWASP-ZAP')).toBe('OWASP ZAP')
+ })
+
+ it('returns empty string for empty input', () => {
+ expect(formatBreadcrumbTitle('')).toBe('')
+ })
+
+ it('handles single character segments', () => {
+ expect(formatBreadcrumbTitle('a-b-c')).toBe('A B C')
+ })
+
+ it('handles numbers in slug', () => {
+ expect(formatBreadcrumbTitle('top-10-2021')).toBe('Top 10 2021')
+ })
+
+ it('handles trailing/leading hyphens', () => {
+ expect(formatBreadcrumbTitle('-test-')).toBe(' Test ')
+ })
+})
diff --git a/frontend/src/app/chapters/[chapterKey]/layout.tsx b/frontend/src/app/chapters/[chapterKey]/layout.tsx
index ce8da734d6..194c749d19 100644
--- a/frontend/src/app/chapters/[chapterKey]/layout.tsx
+++ b/frontend/src/app/chapters/[chapterKey]/layout.tsx
@@ -1,8 +1,21 @@
import { Metadata } from 'next'
-import React from 'react'
+import React, { cache } from 'react'
import { apolloClient } from 'server/apolloClient'
import { GetChapterMetadataDocument } from 'types/__generated__/chapterQueries.generated'
import { generateSeoMetadata } from 'utils/metaconfig'
+import PageLayout from 'components/PageLayout'
+
+const getChapterMetadata = cache(async (chapterKey: string) => {
+ try {
+ const { data } = await apolloClient.query({
+ query: GetChapterMetadataDocument,
+ variables: { key: chapterKey },
+ })
+ return data
+ } catch {
+ return null
+ }
+})
export async function generateMetadata({
params,
@@ -10,12 +23,7 @@ export async function generateMetadata({
params: Promise<{ chapterKey: string }>
}): Promise {
const { chapterKey } = await params
- const { data } = await apolloClient.query({
- query: GetChapterMetadataDocument,
- variables: {
- key: chapterKey,
- },
- })
+ const data = await getChapterMetadata(chapterKey)
const chapter = data?.chapter
return chapter
@@ -28,8 +36,19 @@ export async function generateMetadata({
: null
}
-export default function ChapterDetailsLayout({
+export default async function ChapterDetailsLayout({
children,
-}: Readonly<{ children: React.ReactNode }>) {
- return children
+ params,
+}: Readonly<{
+ children: React.ReactNode
+ params: Promise<{ chapterKey: string }>
+}>) {
+ const { chapterKey } = await params
+ const data = await getChapterMetadata(chapterKey)
+
+ if (!data?.chapter) {
+ return children
+ }
+
+ return {children}
}
diff --git a/frontend/src/app/committees/[committeeKey]/layout.tsx b/frontend/src/app/committees/[committeeKey]/layout.tsx
index 9ae7237dc9..00977a817a 100644
--- a/frontend/src/app/committees/[committeeKey]/layout.tsx
+++ b/frontend/src/app/committees/[committeeKey]/layout.tsx
@@ -1,8 +1,21 @@
import { Metadata } from 'next'
-import React from 'react'
+import React, { cache } from 'react'
import { apolloClient } from 'server/apolloClient'
import { GetCommitteeMetadataDocument } from 'types/__generated__/committeeQueries.generated'
import { generateSeoMetadata } from 'utils/metaconfig'
+import PageLayout from 'components/PageLayout'
+
+const getCommitteeMetadata = cache(async (committeeKey: string) => {
+ try {
+ const { data } = await apolloClient.query({
+ query: GetCommitteeMetadataDocument,
+ variables: { key: committeeKey },
+ })
+ return data
+ } catch {
+ return null
+ }
+})
export async function generateMetadata({
params,
@@ -10,12 +23,7 @@ export async function generateMetadata({
params: Promise<{ committeeKey: string }>
}): Promise {
const { committeeKey } = await params
- const { data } = await apolloClient.query({
- query: GetCommitteeMetadataDocument,
- variables: {
- key: committeeKey,
- },
- })
+ const data = await getCommitteeMetadata(committeeKey)
const committee = data?.committee
return committee
@@ -28,10 +36,19 @@ export async function generateMetadata({
: null
}
-export default function CommitteeDetailsLayout({
+export default async function CommitteeDetailsLayout({
children,
+ params,
}: Readonly<{
children: React.ReactNode
+ params: Promise<{ committeeKey: string }>
}>) {
- return children
+ const { committeeKey } = await params
+ const data = await getCommitteeMetadata(committeeKey)
+
+ if (!data?.committee) {
+ return children
+ }
+
+ return {children}
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 62b643fa3f..40654da428 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,4 +1,5 @@
import { GoogleAnalytics } from '@next/third-parties/google'
+import { BreadcrumbRoot } from 'contexts/BreadcrumbContext'
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import React from 'react'
@@ -6,7 +7,7 @@ import { Providers } from 'wrappers/provider'
import { GTM_ID } from 'utils/env.client'
import { IS_GITHUB_AUTH_ENABLED } from 'utils/env.server'
import AutoScrollToTop from 'components/AutoScrollToTop'
-import BreadCrumbs from 'components/BreadCrumbs'
+import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper'
import Footer from 'components/Footer'
import Header from 'components/Header'
import ScrollToTop from 'components/ScrollToTop'
@@ -71,14 +72,16 @@ export default function RootLayout({
style={{ minHeight: '100vh' }}
>
-
+
+
+