Skip to content

Commit 4f47bda

Browse files
committed
test(seo): crawl the production build for landing SEO regressions
1 parent c1297d3 commit 4f47bda

3 files changed

Lines changed: 268 additions & 0 deletions

File tree

‎.github/scripts/http-e2e.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ case "$group" in
236236
MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
237237
MOBILE_E2E_REPORT_PATH="$report_dir/mobile-e2e-report.json" \
238238
bun run test:mobile:e2e
239+
# The same production build answers as a crawler sees it: statuses, robots, sitemap, JSON-LD.
240+
LANDING_SEO_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
241+
LANDING_SEO_E2E_REPORT_PATH="$report_dir/landing-seo-e2e-report.json" \
242+
bun run test:landing-seo:e2e
239243
;;
240244

241245
*)

‎apps/sim/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"test": "vitest run",
2424
"test:scim:e2e": "bun run scripts/test-scim-e2e.ts",
2525
"test:mobile:e2e": "bun --no-env-file scripts/test-mobile-e2e.ts",
26+
"test:landing-seo:e2e": "bun --no-env-file scripts/test-landing-seo-e2e.ts",
2627
"test:workflow-version-compare:e2e": "bun --no-env-file scripts/test-workflow-version-compare-e2e.ts",
2728
"test:workflow-stop-after:e2e": "bun --no-env-file scripts/test-workflow-stop-after-e2e.ts",
2829
"test:desktop-inbox:e2e": "bun --no-env-file scripts/test-desktop-inbox-e2e.ts",
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import assert from 'node:assert/strict'
2+
import { mkdir, writeFile } from 'node:fs/promises'
3+
import { dirname, resolve } from 'node:path'
4+
import integrationsJson from '@sim/deployment-config/integrations.json'
5+
import { createLogger } from '@sim/logger'
6+
import { getErrorMessage } from '@sim/utils/errors'
7+
8+
/**
9+
* Crawls a running landing site the way a search or AI crawler does: plain GETs, no JavaScript,
10+
* reading status codes, robots directives, the sitemap, and JSON-LD. CI points it at the production
11+
* build (`next start` on the image's placeholder `NEXT_PUBLIC_APP_URL`, so every route renders
12+
* dynamically, as it does in production). It only issues GETs, so it can also be pointed at a
13+
* deployed origin to compare.
14+
*
15+
* Requires LANDING_SEO_E2E_BASE_URL and LANDING_SEO_E2E_REPORT_PATH.
16+
*/
17+
const logger = createLogger('LandingSeoE2E')
18+
19+
function requiredEnvironment(name: string): string {
20+
const value = process.env[name]
21+
assert(value, `${name} must be explicitly provided`)
22+
return value
23+
}
24+
25+
const baseUrl = new URL(requiredEnvironment('LANDING_SEO_E2E_BASE_URL'))
26+
const reportPath = resolve(requiredEnvironment('LANDING_SEO_E2E_REPORT_PATH'))
27+
assert.equal(baseUrl.pathname, '/', 'The base URL must be an origin without a path')
28+
29+
interface Integration {
30+
slug: string
31+
updatedAt?: string
32+
}
33+
const integrations = integrationsJson.integrations as Integration[]
34+
/** `?home` serves the landing page without the self-hosted `/` → `/login` redirect. */
35+
const HOME_PATH = '/?home'
36+
37+
const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] =
38+
[]
39+
const responses: { path: string; status: number; location?: string }[] = []
40+
const startedAt = new Date().toISOString()
41+
42+
async function check(name: string, run: () => Promise<void>) {
43+
const started = performance.now()
44+
try {
45+
await run()
46+
checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) })
47+
logger.info(`PASS ${name}`)
48+
} catch (error) {
49+
checks.push({
50+
name,
51+
status: 'failed',
52+
durationMs: Math.round(performance.now() - started),
53+
error: getErrorMessage(error),
54+
})
55+
logger.error(`FAIL ${name}`, { error: getErrorMessage(error) })
56+
}
57+
}
58+
59+
async function get(
60+
path: string
61+
): Promise<{ status: number; location: string | null; body: string }> {
62+
const response = await fetch(new URL(path, baseUrl), {
63+
redirect: 'manual',
64+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SimLandingSeoE2E/1.0)' },
65+
signal: AbortSignal.timeout(30_000),
66+
})
67+
const body = await response.text()
68+
const location = response.headers.get('location')
69+
responses.push({ path, status: response.status, ...(location ? { location } : {}) })
70+
return { status: response.status, location, body }
71+
}
72+
73+
function robotsDirectives(html: string): string[] {
74+
return [...html.matchAll(/<meta name="robots" content="([^"]*)"/g)].map((match) => match[1])
75+
}
76+
77+
function jsonLdNodes(html: string): Record<string, unknown>[] {
78+
const nodes: Record<string, unknown>[] = []
79+
for (const match of html.matchAll(
80+
/<script type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs
81+
)) {
82+
const parsed: unknown = JSON.parse(match[1])
83+
const roots = Array.isArray(parsed) ? parsed : [parsed]
84+
for (const root of roots as Record<string, unknown>[]) {
85+
nodes.push(root)
86+
if (Array.isArray(root['@graph']))
87+
nodes.push(...(root['@graph'] as Record<string, unknown>[]))
88+
}
89+
}
90+
return nodes
91+
}
92+
93+
/** Paths and lastmod values from the sitemap, keyed by path. */
94+
async function readSitemap(): Promise<Map<string, string | undefined>> {
95+
const { status, body } = await get('/sitemap.xml')
96+
assert.equal(status, 200, 'sitemap.xml must be served')
97+
const entries = new Map<string, string | undefined>()
98+
for (const match of body.matchAll(/<url>(.*?)<\/url>/gs)) {
99+
const loc = /<loc>([^<]+)<\/loc>/.exec(match[1])?.[1]
100+
if (!loc) continue
101+
entries.set(new URL(loc).pathname, /<lastmod>([^<]+)<\/lastmod>/.exec(match[1])?.[1])
102+
}
103+
return entries
104+
}
105+
106+
async function run() {
107+
const sitemap = await readSitemap()
108+
const sitemapPaths = [...sitemap.keys()]
109+
const firstPath = (pattern: RegExp) => {
110+
const path = sitemapPaths.find((candidate) => pattern.test(candidate))
111+
assert(path, `The sitemap lists no page matching ${pattern}`)
112+
return path
113+
}
114+
115+
await check('unknown slugs return 404 with a single noindex directive', async () => {
116+
const unknown = [
117+
'/integrations/not-a-real-integration',
118+
'/library/not-a-real-post',
119+
'/blog/not-a-real-post',
120+
'/blog/authors/not-a-real-author',
121+
'/library/authors/not-a-real-author',
122+
'/models/not-a-real-provider',
123+
'/models/anthropic/not-a-real-model',
124+
]
125+
const failures: string[] = []
126+
for (const path of unknown) {
127+
const { status, body } = await get(path)
128+
const robots = robotsDirectives(body)
129+
const indexable = robots.some((directive) => !directive.includes('noindex'))
130+
if (status !== 404 || indexable) {
131+
failures.push(`${path} → ${status}, robots ${JSON.stringify(robots)}`)
132+
}
133+
}
134+
assert.deepEqual(failures, [])
135+
})
136+
137+
await check('known pages in each slug family render and are indexable', async () => {
138+
const known = [
139+
'/blog',
140+
'/library',
141+
firstPath(/^\/blog\/(?!authors\/|tags)[^/]+$/),
142+
firstPath(/^\/library\/(?!authors\/|tags)[^/]+$/),
143+
firstPath(/^\/integrations\/[^/]+$/),
144+
firstPath(/^\/models\/anthropic\/[^/]+$/),
145+
]
146+
const failures: string[] = []
147+
for (const path of known) {
148+
const { status, body } = await get(path)
149+
const robots = robotsDirectives(body)
150+
if (status !== 200 || robots.some((directive) => directive.includes('noindex'))) {
151+
failures.push(`${path} → ${status}, robots ${JSON.stringify(robots)}`)
152+
}
153+
}
154+
assert.deepEqual(failures, [])
155+
})
156+
157+
await check('underscore integration slugs redirect to the hyphenated page', async () => {
158+
const { status, location } = await get('/integrations/google_drive')
159+
assert.equal(status, 308)
160+
assert.equal(location && new URL(location, baseUrl).pathname, '/integrations/google-drive')
161+
})
162+
163+
await check('integration sitemap dates are per integration, not one catalog date', async () => {
164+
const mismatches: string[] = []
165+
for (const integration of integrations) {
166+
const lastmod = sitemap.get(`/integrations/${integration.slug}`)
167+
assert(integration.updatedAt, `${integration.slug} has no updatedAt in the catalog`)
168+
if (lastmod?.slice(0, 10) !== integration.updatedAt) {
169+
mismatches.push(
170+
`${integration.slug}: sitemap ${lastmod} vs catalog ${integration.updatedAt}`
171+
)
172+
}
173+
}
174+
assert.deepEqual(mismatches.slice(0, 10), [], `${mismatches.length} integration dates differ`)
175+
const distinctDates = new Set(integrations.map((integration) => integration.updatedAt))
176+
assert(distinctDates.size > 1, 'Every integration carries the same date')
177+
})
178+
179+
await check('integration page JSON-LD and visible date use the integration date', async () => {
180+
const integration = integrations.find((entry) => entry.slug === 'slack')
181+
assert(integration?.updatedAt)
182+
const { body } = await get('/integrations/slack')
183+
const webPage = jsonLdNodes(body).find((node) => node['@type'] === 'WebPage')
184+
assert.equal(webPage?.dateModified, integration.updatedAt)
185+
assert(
186+
body.includes(`dateTime="${integration.updatedAt}"`),
187+
'Visible last-updated date differs'
188+
)
189+
})
190+
191+
await check('reseller model pages are in the sitemap and name their provider', async () => {
192+
const resellerPath = firstPath(/^\/models\/(?:bedrock|vertex|azure-openai)\/[^/]+$/)
193+
const { status, body } = await get(resellerPath)
194+
assert.equal(status, 200)
195+
const title = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? ''
196+
assert.match(title, / on /, `${resellerPath} title "${title}" does not name the reseller`)
197+
})
198+
199+
await check('footer integration links stay on the landing site', async () => {
200+
const { body } = await get(HOME_PATH)
201+
assert(body.includes('href="/integrations/slack"'), 'Footer does not link /integrations/slack')
202+
assert(!body.includes('docs.sim.ai/integrations/'), 'Footer still links docs integration pages')
203+
assert(!body.includes('docs.sim.ai/mothership'), 'Footer links a redirecting docs path')
204+
})
205+
206+
await check(
207+
'home JSON-LD names no retired plan and ties the product to the organization',
208+
async () => {
209+
const { body } = await get(HOME_PATH)
210+
assert(!/Community Plan/.test(body), 'Home still names the retired Community plan')
211+
const software = jsonLdNodes(body).find((node) => String(node['@id']).endsWith('#software'))
212+
assert(software, 'Home has no #software node')
213+
const publisher = software.publisher as Record<string, unknown> | undefined
214+
assert(String(publisher?.['@id']).endsWith('#organization'), '#software has no publisher')
215+
}
216+
)
217+
218+
await check('pricing declares its offers on the canonical product node', async () => {
219+
const { body } = await get('/pricing')
220+
const product = jsonLdNodes(body).find((node) => Array.isArray(node.offers))
221+
assert(product, 'Pricing has no offers')
222+
assert(String(product['@id']).endsWith('#software'), `Offers sit on ${product['@id']}`)
223+
})
224+
225+
await check('blog, library, and changelog advertise their RSS feeds', async () => {
226+
const missing: string[] = []
227+
for (const [path, feed] of [
228+
['/blog', '/blog/rss.xml'],
229+
['/library', '/library/rss.xml'],
230+
['/changelog', '/changelog.xml'],
231+
]) {
232+
const { body } = await get(path)
233+
const link = /<link rel="alternate" type="application\/rss\+xml" href="([^"]+)"/.exec(body)
234+
if (!link || new URL(link[1]).pathname !== feed) missing.push(path)
235+
}
236+
assert.deepEqual(missing, [])
237+
})
238+
239+
await mkdir(dirname(reportPath), { recursive: true })
240+
await writeFile(
241+
reportPath,
242+
JSON.stringify(
243+
{
244+
startedAt,
245+
completedAt: new Date().toISOString(),
246+
baseUrl: baseUrl.origin,
247+
checks,
248+
responses,
249+
},
250+
null,
251+
2
252+
)
253+
)
254+
}
255+
256+
await run()
257+
const failures = checks.filter((result) => result.status === 'failed')
258+
logger.info('Landing SEO checks complete', {
259+
passed: checks.length - failures.length,
260+
failed: failures.length,
261+
reportPath,
262+
})
263+
if (failures.length) process.exitCode = 1

0 commit comments

Comments
 (0)