Skip to content

Commit 90b5bd8

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
2 parents 179d80c + 5730e62 commit 90b5bd8

14 files changed

Lines changed: 648 additions & 95 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+
})

apps/sim/app/(landing)/comparisons/[provider]/page.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/c
88
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile'
99
import { ComparisonCards } from '@/app/(landing)/comparisons/components/comparison-cards'
1010
import { ComparisonTable } from '@/app/(landing)/comparisons/components/comparison-table'
11+
import { ProseText } from '@/app/(landing)/comparisons/components/prose-text'
1112
import {
1213
ALL_COMPETITORS,
1314
buildBottomLine,
@@ -173,6 +174,11 @@ export default async function ComparisonProviderPage({
173174
>
174175
Sim vs {competitor.name}
175176
</h1>
177+
{competitor.leadAnswer ? (
178+
<p className='max-w-[720px] text-[var(--text-body)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
179+
<ProseText prose={competitor.leadAnswer} />
180+
</p>
181+
) : null}
176182
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
177183
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents
178184
visually, conversationally, or with code. Here is how Sim compares to{' '}
@@ -201,6 +207,22 @@ export default async function ComparisonProviderPage({
201207

202208
<div className='mx-auto w-full max-w-[1446px]'>
203209
<div className='mx-12 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
210+
{competitor.betterThanAnswer ? (
211+
<>
212+
<section aria-labelledby='better-than-heading' className='px-6 py-10'>
213+
<h2
214+
id='better-than-heading'
215+
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
216+
>
217+
Is Sim better than {competitor.name}?
218+
</h2>
219+
<p className='max-w-[720px] text-[var(--text-body)] text-small leading-[150%]'>
220+
<ProseText prose={competitor.betterThanAnswer} />
221+
</p>
222+
</section>
223+
<div className='h-px w-full bg-[var(--border)]' />
224+
</>
225+
) : null}
204226
<div className='grid grid-cols-1 sm:grid-cols-2'>
205227
<section
206228
aria-labelledby='what-is-sim-heading'
@@ -240,16 +262,45 @@ export default async function ComparisonProviderPage({
240262

241263
<div className='h-px w-full bg-[var(--border)]' />
242264

243-
<section aria-labelledby='comparison-table-heading' className='px-6 py-10'>
265+
<section aria-labelledby='comparison-table-heading' className='px-6 pt-10 pb-4'>
244266
<h2
245267
id='comparison-table-heading'
246268
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
247269
>
248270
Sim vs {competitor.name}: feature-by-feature comparison
249271
</h2>
250-
<ComparisonTable sim={simProfile} competitor={competitor} />
272+
<p className='max-w-[720px] text-[var(--text-body)] text-small leading-[150%]'>
273+
The sections below compare Sim and {competitor.name} across platform and deployment,
274+
pricing, security and compliance, AI capabilities, integrations, observability, and
275+
support.
276+
</p>
251277
</section>
252278

279+
{COMPARISON_SECTIONS.map((section) => {
280+
const sectionIntro = competitor.sectionIntros?.[section.group]
281+
282+
return (
283+
<section
284+
key={section.group}
285+
aria-labelledby={`comparison-section-${section.group}-heading`}
286+
className='px-6 pb-10'
287+
>
288+
<h2
289+
id={`comparison-section-${section.group}-heading`}
290+
className='mb-3 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
291+
>
292+
{section.title}
293+
</h2>
294+
{sectionIntro ? (
295+
<p className='mb-4 max-w-[720px] text-[var(--text-body)] text-small leading-[150%]'>
296+
<ProseText prose={sectionIntro} />
297+
</p>
298+
) : null}
299+
<ComparisonTable sim={simProfile} competitor={competitor} section={section} />
300+
</section>
301+
)
302+
})}
303+
253304
<div className='h-px w-full bg-[var(--border)]' />
254305

255306
<div className='grid grid-cols-1 lg:grid-cols-2'>

apps/sim/app/(landing)/comparisons/comparison-sections.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function defineSection<G extends keyof ComparisonFacts>(section: {
5555
export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [
5656
defineSection({
5757
group: 'platform',
58-
title: 'Platform',
58+
title: 'Platform & deployment',
5959
rows: [
6060
{ key: 'builderType', label: 'Builder type' },
6161
{ key: 'learningCurve', label: 'Learning curve' },

0 commit comments

Comments
 (0)