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
128 changes: 128 additions & 0 deletions __tests__/CompareCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { CompareCard } from '@/components/CompareCard'

afterEach(() => {
cleanup()
})

describe('CompareCard', () => {
it('renders use case heading, name, summary, strengths list, and gaps list', () => {
render(
<CompareCard
useCase="I want an example use case."
name="Example Tool"
summary="One-line summary for the tool."
strengths={['Strength A', 'Strength B']}
gaps={['Gap A']}
/>,
)

expect(
screen.getByRole('heading', { level: 2, name: 'I want an example use case.' }),
).toBeInTheDocument()
expect(screen.getByText('Example Tool')).toBeInTheDocument()
expect(screen.getByText('One-line summary for the tool.')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 3, name: 'What it does well' })).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 3, name: 'What it does not cover' }),
).toBeInTheDocument()
expect(screen.getByText('Strength A')).toBeInTheDocument()
expect(screen.getByText('Strength B')).toBeInTheDocument()
expect(screen.getByText('Gap A')).toBeInTheDocument()
})

it('renders Learn more link with href, target, rel, and aria-label when url is provided', () => {
render(
<CompareCard
useCase="Use case"
name="Agent Safehouse"
summary="Summary"
strengths={[]}
gaps={[]}
url="https://agent-safehouse.dev"
/>,
)

const link = screen.getByRole('link', { name: 'Learn more about Agent Safehouse' })
expect(link).toHaveAttribute('href', 'https://agent-safehouse.dev')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})

it('does not render Learn more when url is omitted', () => {
render(
<CompareCard useCase="Use case" name="Tool" summary="Summary" strengths={[]} gaps={[]} />,
)

expect(screen.queryByRole('link', { name: /Learn more about/ })).not.toBeInTheDocument()
})

it('renders tracked CTA when highlight and trackedCta are provided', () => {
render(
<CompareCard
useCase="Use case"
name="Shield"
summary="Summary"
strengths={[]}
gaps={[]}
highlight
trackedCta={{
href: 'https://app.multicorn.ai/signup',
eventName: 'test_click',
label: 'Sign up for Shield',
}}
/>,
)

expect(screen.getByRole('link', { name: 'Sign up for Shield' })).toHaveAttribute(
'href',
'https://app.multicorn.ai/signup',
)
})

it('does not render CTA when highlight is false and trackedCta is omitted', () => {
render(
<CompareCard
useCase="Use case"
name="Tool"
summary="Summary"
strengths={[]}
gaps={[]}
highlight={false}
/>,
)

expect(screen.queryByRole('link', { name: /Sign up/ })).not.toBeInTheDocument()
})

it('renders strength and gap items in order', () => {
const { container } = render(
<CompareCard
useCase="Use case"
name="Tool"
summary="Summary"
strengths={['first strength', 'second strength', 'third strength']}
gaps={['first gap', 'second gap']}
/>,
)

const article = container.querySelector('article')
expect(article).not.toBeNull()
if (article === null) {
return
}
const lists = article.querySelectorAll('ul')
expect(lists).toHaveLength(2)

const strengthItems = within(lists[0] as HTMLElement).getAllByRole('listitem')
expect(strengthItems.map((li) => li.textContent)).toEqual([
'first strength',
'second strength',
'third strength',
])

const gapItems = within(lists[1] as HTMLElement).getAllByRole('listitem')
expect(gapItems.map((li) => li.textContent)).toEqual(['first gap', 'second gap'])
})
})
45 changes: 45 additions & 0 deletions __tests__/shield-compare-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import ShieldComparePage from '@/app/shield/compare/page'
import { COMPARE_COMPETITORS, SHIELD_COMPARE } from '@/lib/compare-data'

afterEach(() => {
cleanup()
})

describe('/shield/compare page', () => {
it('renders all four use-case tool names and no visible TODO placeholder strings', () => {
const { container } = render(<ShieldComparePage />)

for (const competitor of COMPARE_COMPETITORS) {
expect(screen.getByText(competitor.name)).toBeInTheDocument()
}

expect(screen.getByText(SHIELD_COMPARE.name)).toBeInTheDocument()

expect(container.textContent ?? '').not.toContain('TODO')
})

it('renders exactly four article cards', () => {
const { container } = render(<ShieldComparePage />)
expect(container.querySelectorAll('article')).toHaveLength(4)
})

it('renders Multicorn Shield as the last compare card', () => {
const { container } = render(<ShieldComparePage />)
const articles = container.querySelectorAll('article')
expect(articles).toHaveLength(4)
const lastHeading = articles[3]?.querySelector('h2')
expect(lastHeading?.textContent).toBe(SHIELD_COMPARE.useCase)
})

it('renders the hero heading about choosing the right tool', () => {
render(<ShieldComparePage />)
expect(
screen.getByRole('heading', {
level: 1,
name: /Choosing the right tool/,
}),
).toBeInTheDocument()
})
})
37 changes: 37 additions & 0 deletions __tests__/shield-page-compare-cta.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import ShieldPage from '@/app/shield/page'

beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})

afterEach(() => {
cleanup()
})

describe('/shield page compare CTA', () => {
it('shows compare section heading and link to /shield/compare', () => {
render(<ShieldPage />)

expect(
screen.getByRole('heading', { name: /Not sure if Shield is right for you/i }),
).toBeInTheDocument()

const link = screen.getByRole('link', { name: /Compare Shield to alternatives/i })
expect(link).toHaveAttribute('href', '/shield/compare')
})
})
120 changes: 120 additions & 0 deletions app/shield/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Metadata } from 'next'
import { CompareCard } from '@/components/CompareCard'
import { Footer } from '@/components/Footer'
import { TrackedCtaLink } from '@/components/TrackedCtaLink'
import { COMPARE_COMPETITORS, SHIELD_COMPARE } from '@/lib/compare-data'
import { SIGNUP_URL } from '@/lib/urls'

const CANONICAL_URL = 'https://multicorn.ai/shield/compare'
/** Same default OG asset as other product pages (e.g. /shield, /pricing). */
const OG_IMAGE_URL = 'https://multicorn.ai/images/og-image.png'

const META_DESCRIPTION =
'Compare Multicorn Shield to Agent Safehouse, agentsh, and AgentGate. Find the right AI agent control tool for your team based on what you need to protect.'

export const metadata: Metadata = {
title: 'Compare tools for AI agent control | Multicorn Shield',
description: META_DESCRIPTION,
alternates: {
canonical: CANONICAL_URL,
},
openGraph: {
title: 'Compare tools for AI agent control | Multicorn Shield',
description: META_DESCRIPTION,
url: CANONICAL_URL,
siteName: 'Multicorn',
type: 'website',
images: [
{
url: OG_IMAGE_URL,
width: 1200,
height: 630,
alt: 'Compare tools for AI agent control',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Compare tools for AI agent control | Multicorn Shield',
description: META_DESCRIPTION,
images: [OG_IMAGE_URL],
},
}

export default function ShieldComparePage() {
return (
<>
<main>
<section className="relative overflow-hidden px-6 pb-10 pt-20 sm:pb-16 sm:pt-28">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-b from-primary/5 via-indigo/5 to-transparent"
/>
<div className="mx-auto max-w-content text-center">
<h1 className="mx-auto max-w-3xl text-4xl font-bold tracking-tight text-text-primary sm:text-5xl">
Choosing the right tool for AI agent control
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-text-secondary sm:text-xl">
Different jobs need different kinds of control. The sections below are four common
situations. Each one names a type of tool, what it is good at, and what it does not
try to solve. Read them in order, or jump to the one that matches your situation.
</p>
</div>
</section>

<section className="px-6 pb-14 sm:pb-24">
<div className="mx-auto flex max-w-3xl flex-col gap-10">
{COMPARE_COMPETITORS.map((competitor) => (
<CompareCard
key={competitor.id}
useCase={competitor.useCase}
name={competitor.name}
summary={competitor.summary}
strengths={competitor.strengths}
gaps={competitor.gaps}
url={competitor.url}
/>
))}

<CompareCard
useCase={SHIELD_COMPARE.useCase}
name={SHIELD_COMPARE.name}
summary={SHIELD_COMPARE.summary}
strengths={SHIELD_COMPARE.strengths}
gaps={SHIELD_COMPARE.gaps}
highlight
trackedCta={{
href: SIGNUP_URL,
eventName: 'compare_shield_card_signup_click',
eventProps: { location: 'compare_shield_card' },
label: 'Sign up for Shield',
}}
/>
</div>
</section>

<section className="border-t border-border-light bg-surface-secondary px-6 py-14 sm:py-20">
<div className="mx-auto max-w-content text-center">
<h2 className="text-2xl font-bold tracking-tight text-text-primary sm:text-3xl">
Ready for team-wide governance?
</h2>
<p className="mx-auto mt-4 max-w-xl text-lg text-text-secondary">
Start free. No credit card required. Connect agents and MCP tools when you are ready.
</p>
<div className="mt-10 flex justify-center">
<TrackedCtaLink
href={SIGNUP_URL}
className="inline-flex min-h-[44px] items-center rounded-lg bg-primary px-8 py-3 text-base font-semibold text-white shadow-sm hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 motion-safe:transition-colors"
eventName="compare_signup_click"
eventProps={{ location: 'compare_bottom_cta' }}
>
Start for free
</TrackedCtaLink>
</div>
</div>
</section>
</main>
<Footer />
</>
)
}
Loading
Loading