Skip to content

Commit cdc56cb

Browse files
committed
test(comparisons): cover the split-table and prose-link rendering paths
Follow-up to #7560, which shipped the seven-table split without automated coverage. Guards the regressions that split makes possible: a silently dropped fact group, a section heading whose id no longer pairs with its aria-labelledby, a table label that stops distinguishing the seven tables, and a prose link that loses its external hardening or stops routing an internal path through Next. Covers one profile with every optional prose field and one with none. Each assertion was verified red against a mutated build before landing.
1 parent e698d26 commit cdc56cb

1 file changed

Lines changed: 129 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { ReactNode } from 'react'
5+
import { renderToStaticMarkup } from 'react-dom/server'
6+
import { describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@sim/emcn', () => ({
9+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
10+
Tooltip: {
11+
Root: ({ children }: { children: ReactNode }) => <>{children}</>,
12+
Trigger: ({ children }: { children: ReactNode }) => <>{children}</>,
13+
Content: () => null,
14+
},
15+
}))
16+
17+
vi.mock('@sim/emcn/icons', () => ({
18+
Check: () => null,
19+
X: () => null,
20+
}))
21+
22+
vi.mock('next/link', () => ({
23+
default: ({ href, children }: { href: string; children: ReactNode }) => (
24+
<a href={href}>{children}</a>
25+
),
26+
}))
27+
28+
vi.mock('@/app/(landing)/components', () => ({ BackLink: () => null }))
29+
vi.mock('@/app/(landing)/components/cta/cta', () => ({ Cta: () => null }))
30+
vi.mock('@/app/(landing)/components/json-ld', () => ({ JsonLd: () => null }))
31+
vi.mock('@/app/(landing)/components/landing-faq', () => ({ LandingFAQ: () => null }))
32+
vi.mock('@/app/(landing)/comparisons/components/brand-icon-tile', () => ({
33+
BrandIconTile: () => null,
34+
SimIconTile: () => null,
35+
}))
36+
vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({
37+
ComparisonCards: () => null,
38+
}))
39+
40+
import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page'
41+
import { COMPARISON_SECTIONS } from '@/app/(landing)/comparisons/comparison-sections'
42+
43+
const TOTAL_FACT_ROWS = COMPARISON_SECTIONS.reduce(
44+
(total, section) => total + section.rows.length,
45+
0
46+
)
47+
48+
async function renderProvider(provider: string): Promise<string> {
49+
const element = await ComparisonProviderPage({ params: Promise.resolve({ provider }) })
50+
return renderToStaticMarkup(element)
51+
}
52+
53+
function countMatches(markup: string, pattern: RegExp): number {
54+
return markup.match(pattern)?.length ?? 0
55+
}
56+
57+
/**
58+
* The opening tag of the anchor whose entire body is `text`. Anchored on the
59+
* link text rather than the href because source-citation links elsewhere on the
60+
* page point at some of the same URLs — matching on href alone silently passes
61+
* against the wrong anchor.
62+
*/
63+
function anchorWrapping(markup: string, text: string): string {
64+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
65+
return markup.match(new RegExp(`<a [^>]*>${escaped}</a>`))?.[0] ?? ''
66+
}
67+
68+
/** Section titles contain `&`, which React escapes on the way into the markup. */
69+
function escapeForMarkup(value: string): string {
70+
return value.replace(/&/g, '&amp;')
71+
}
72+
73+
describe('ComparisonProviderPage', () => {
74+
it('renders one table per section with every fact row, for a profile with optional prose', async () => {
75+
const markup = await renderProvider('dust')
76+
77+
expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length)
78+
expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS)
79+
})
80+
81+
it('renders the same section and row inventory for a profile without optional prose', async () => {
82+
const markup = await renderProvider('n8n')
83+
84+
expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length)
85+
expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS)
86+
})
87+
88+
it('gives every section heading an id its section aria-labelledby points at', async () => {
89+
const markup = await renderProvider('dust')
90+
91+
for (const section of COMPARISON_SECTIONS) {
92+
const headingId = `comparison-section-${section.group}-heading`
93+
expect(markup).toContain(`aria-labelledby="${headingId}"`)
94+
expect(markup).toContain(`id="${headingId}"`)
95+
}
96+
})
97+
98+
it('labels each section table distinctly so the seven tables are distinguishable', async () => {
99+
const markup = await renderProvider('dust')
100+
101+
for (const section of COMPARISON_SECTIONS) {
102+
expect(markup).toContain(`aria-label="Sim vs Dust: ${escapeForMarkup(section.title)}"`)
103+
}
104+
})
105+
106+
it('renders the lead answer and verdict only when the profile supplies them', async () => {
107+
const withProse = await renderProvider('dust')
108+
const withoutProse = await renderProvider('n8n')
109+
110+
expect(withProse).toContain('Is Sim better than Dust?')
111+
expect(withProse).toContain('id="better-than-heading"')
112+
expect(withoutProse).not.toContain('Is Sim better than n8n?')
113+
expect(withoutProse).not.toContain('id="better-than-heading"')
114+
})
115+
116+
it('hardens external prose links and keeps internal ones as plain paths', async () => {
117+
const markup = await renderProvider('openai-agentkit')
118+
119+
const external = anchorWrapping(markup, 'self-hosting')
120+
expect(external).toContain('href="https://docs.sim.ai/platform/self-hosting"')
121+
expect(external).toContain('target="_blank"')
122+
expect(external).toContain('rel="noopener noreferrer"')
123+
124+
const internal = anchorWrapping(markup, 'Sim combines a per-user subscription')
125+
expect(internal).toContain('href="/pricing"')
126+
expect(internal).not.toContain('target=')
127+
expect(internal).not.toContain('rel=')
128+
})
129+
})

0 commit comments

Comments
 (0)