Skip to content

Commit 5730e62

Browse files
authored
test(comparisons): cover the split-table and prose-link rendering paths (#7561)
* 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. * test(comparisons): assert the prose bodies render, not just their headings The section-presence assertions checked the verdict heading and its id but never the rendered prose, so they passed against a build that emitted an empty lead answer, verdict, or section intro. Assert the text itself, derived from the profile data, and assert its absence on a profile that supplies none. Verified red against a build that keeps the headings and empties the bodies.
1 parent e698d26 commit 5730e62

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 type { Prose } from '@/lib/compare/data'
41+
import { dustProfile } from '@/lib/compare/data'
42+
import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page'
43+
import { COMPARISON_SECTIONS } from '@/app/(landing)/comparisons/comparison-sections'
44+
45+
const TOTAL_FACT_ROWS = COMPARISON_SECTIONS.reduce(
46+
(total, section) => total + section.rows.length,
47+
0
48+
)
49+
50+
async function renderProvider(provider: string): Promise<string> {
51+
const element = await ComparisonProviderPage({ params: Promise.resolve({ provider }) })
52+
return renderToStaticMarkup(element)
53+
}
54+
55+
function countMatches(markup: string, pattern: RegExp): number {
56+
return markup.match(pattern)?.length ?? 0
57+
}
58+
59+
/**
60+
* The opening tag of the anchor whose entire body is `text`. Anchored on the
61+
* link text rather than the href because source-citation links elsewhere on the
62+
* page point at some of the same URLs — matching on href alone silently passes
63+
* against the wrong anchor.
64+
*/
65+
function anchorWrapping(markup: string, text: string): string {
66+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67+
return markup.match(new RegExp(`<a [^>]*>${escaped}</a>`))?.[0] ?? ''
68+
}
69+
70+
/** Mirrors React's text escaping so data-derived copy can be matched in markup. */
71+
function escapeForMarkup(value: string): string {
72+
return value
73+
.replace(/&/g, '&amp;')
74+
.replace(/</g, '&lt;')
75+
.replace(/>/g, '&gt;')
76+
.replace(/"/g, '&quot;')
77+
.replace(/'/g, '&#x27;')
78+
}
79+
80+
/** The rendered text of a {@link Prose} run, links flattened to their labels. */
81+
function proseText(prose: Prose | undefined): string {
82+
if (!prose) throw new Error('expected the fixture profile to supply this prose field')
83+
return escapeForMarkup(prose.map((s) => (typeof s === 'string' ? s : s.text)).join(''))
84+
}
85+
86+
describe('ComparisonProviderPage', () => {
87+
it('renders one table per section with every fact row, for a profile with optional prose', async () => {
88+
const markup = await renderProvider('dust')
89+
90+
expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length)
91+
expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS)
92+
})
93+
94+
it('renders the same section and row inventory for a profile without optional prose', async () => {
95+
const markup = await renderProvider('n8n')
96+
97+
expect(countMatches(markup, /role="table"/g)).toBe(COMPARISON_SECTIONS.length)
98+
expect(countMatches(markup, /role="rowheader"/g)).toBe(TOTAL_FACT_ROWS)
99+
})
100+
101+
it('gives every section heading an id its section aria-labelledby points at', async () => {
102+
const markup = await renderProvider('dust')
103+
104+
for (const section of COMPARISON_SECTIONS) {
105+
const headingId = `comparison-section-${section.group}-heading`
106+
expect(markup).toContain(`aria-labelledby="${headingId}"`)
107+
expect(markup).toContain(`id="${headingId}"`)
108+
}
109+
})
110+
111+
it('labels each section table distinctly so the seven tables are distinguishable', async () => {
112+
const markup = await renderProvider('dust')
113+
114+
for (const section of COMPARISON_SECTIONS) {
115+
expect(markup).toContain(`aria-label="Sim vs Dust: ${escapeForMarkup(section.title)}"`)
116+
}
117+
})
118+
119+
it('renders the lead answer and verdict bodies only when the profile supplies them', async () => {
120+
const withProse = await renderProvider('dust')
121+
const withoutProse = await renderProvider('n8n')
122+
const lead = proseText(dustProfile.leadAnswer)
123+
const verdict = proseText(dustProfile.betterThanAnswer)
124+
125+
expect(withProse).toContain('Is Sim better than Dust?')
126+
expect(withProse).toContain('id="better-than-heading"')
127+
expect(withProse).toContain(lead)
128+
expect(withProse).toContain(verdict)
129+
130+
expect(withoutProse).not.toContain('Is Sim better than n8n?')
131+
expect(withoutProse).not.toContain('id="better-than-heading"')
132+
expect(withoutProse).not.toContain(lead)
133+
expect(withoutProse).not.toContain(verdict)
134+
})
135+
136+
it('renders every section intro body the profile supplies, and none when it supplies none', async () => {
137+
const withProse = await renderProvider('dust')
138+
const withoutProse = await renderProvider('n8n')
139+
140+
for (const section of COMPARISON_SECTIONS) {
141+
const intro = proseText(dustProfile.sectionIntros?.[section.group])
142+
expect(withProse).toContain(intro)
143+
expect(withoutProse).not.toContain(intro)
144+
}
145+
})
146+
147+
it('hardens external prose links and keeps internal ones as plain paths', async () => {
148+
const markup = await renderProvider('openai-agentkit')
149+
150+
const external = anchorWrapping(markup, 'self-hosting')
151+
expect(external).toContain('href="https://docs.sim.ai/platform/self-hosting"')
152+
expect(external).toContain('target="_blank"')
153+
expect(external).toContain('rel="noopener noreferrer"')
154+
155+
const internal = anchorWrapping(markup, 'Sim combines a per-user subscription')
156+
expect(internal).toContain('href="/pricing"')
157+
expect(internal).not.toContain('target=')
158+
expect(internal).not.toContain('rel=')
159+
})
160+
})

0 commit comments

Comments
 (0)