Skip to content

Commit c901457

Browse files
gmoonclaude
andcommitted
Add smoke tests, JUnit reporting, and test summary script
- Add scripts/smoke.test.ts with 27 tests against live site (status codes, trailing slash redirects, security headers, content, static assets) - Add JUnit XML reporters to vitest configs (test-results/unit.xml, smoke.xml) - Add scripts/test-summary.mjs for aggregated test reporting (table + --md) - Add prerender tests: /reader route, homepage noscript nav, sitemap, 404 - Add Makefile targets: smoke-test, test-all (unit + smoke + summary) - Add test-results/ to .gitignore - Exclude .mjs from eslint (Node globals) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f06437f commit c901457

9 files changed

Lines changed: 393 additions & 10 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ coverage
66
*.local
77
.env
88
.env.*
9+
10+
# Test results (JUnit XML)
11+
test-results/

Makefile

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: ci install format lint typecheck build build-ui test clean pre-commit pre-push
1+
.PHONY: ci install format lint typecheck build build-ui test smoke-test test-all clean pre-commit pre-push help
22

33
# Pre-commit gate: fast checks (format + lint)
44
pre-commit: format lint
@@ -33,15 +33,28 @@ build: build-ui
3333
test:
3434
npm test
3535

36+
# Smoke tests against live site (run after deploy)
37+
smoke-test:
38+
npm run test:smoke
39+
40+
# Run all tests (unit + smoke) with JUnit XML summary
41+
test-all:
42+
@rm -rf test-results && mkdir -p test-results
43+
npm test
44+
npm run test:smoke
45+
@node scripts/test-summary.mjs
46+
3647
clean:
37-
rm -rf dist packages/*/dist node_modules
48+
rm -rf dist packages/*/dist node_modules test-results coverage
3849

3950
help:
4051
@echo "Available targets:"
41-
@echo " make pre-commit - Check formatting + lint (run before commit)"
42-
@echo " make pre-push - Full check: format + lint + typecheck + test + build (run before push)"
43-
@echo " make ci - Full CI with clean install"
44-
@echo " make build-ui - Build @forkzero/ui package"
45-
@echo " make build - Build for production"
46-
@echo " make test - Run tests with coverage"
47-
@echo " make clean - Remove dist and node_modules"
52+
@echo " make pre-commit - Check formatting + lint (run before commit)"
53+
@echo " make pre-push - Full check: format + lint + typecheck + test + build (run before push)"
54+
@echo " make ci - Full CI with clean install"
55+
@echo " make build-ui - Build @forkzero/ui package"
56+
@echo " make build - Build for production"
57+
@echo " make test - Run unit tests with coverage"
58+
@echo " make smoke-test - Run smoke tests against live site"
59+
@echo " make test-all - Run all tests with JUnit XML summary"
60+
@echo " make clean - Remove dist, test-results, and node_modules"

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint'
33
import reactHooks from 'eslint-plugin-react-hooks'
44

55
export default tseslint.config(
6-
{ ignores: ['dist', 'node_modules', 'packages/*/dist'] },
6+
{ ignores: ['dist', 'node_modules', 'packages/*/dist', 'scripts/*.mjs'] },
77
js.configs.recommended,
88
...tseslint.configs.recommended,
99
{

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"preview": "vite preview",
1313
"test": "vitest run --coverage",
1414
"test:fast": "vitest run",
15+
"test:smoke": "vitest run --config vitest.smoke.config.ts",
1516
"test:watch": "vitest",
1617
"lint": "eslint src scripts packages/ui/src",
1718
"lint:fix": "eslint src scripts packages/ui/src --fix",

scripts/prerender.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ describe('pre-rendered homepage', () => {
4444
expect(html).toContain('knowledge graph')
4545
})
4646

47+
it('has noscript navigation links', () => {
48+
const html = readFileSync(file, 'utf-8')
49+
expect(html).toContain('<nav>')
50+
expect(html).toContain('href="/getting-started"')
51+
expect(html).toContain('href="/blog"')
52+
expect(html).toContain('href="/privacy"')
53+
})
54+
4755
it('has Organization JSON-LD', () => {
4856
const html = readFileSync(file, 'utf-8')
4957
expect(html).toContain('application/ld+json')
@@ -105,6 +113,47 @@ describe('pre-rendered getting-started', () => {
105113
})
106114
})
107115

116+
describe('pre-rendered reader', () => {
117+
const file = join(distDir, 'reader', 'index.html')
118+
119+
it('exists', () => {
120+
expect(existsSync(file)).toBe(true)
121+
})
122+
123+
it('has correct title', () => {
124+
const html = readFileSync(file, 'utf-8')
125+
expect(html).toContain('<title>Lattice Dashboard — Forkzero</title>')
126+
})
127+
128+
it('has meta description', () => {
129+
const html = readFileSync(file, 'utf-8')
130+
expect(html).toContain('<meta name="description"')
131+
expect(html).toContain('Interactive viewer')
132+
})
133+
134+
it('has og:type website', () => {
135+
const html = readFileSync(file, 'utf-8')
136+
expect(html).toContain('og:type" content="website"')
137+
})
138+
139+
it('has noscript fallback', () => {
140+
const html = readFileSync(file, 'utf-8')
141+
expect(html).toContain('<noscript>')
142+
expect(html).toContain('Lattice Dashboard')
143+
})
144+
145+
it('has WebPage JSON-LD', () => {
146+
const html = readFileSync(file, 'utf-8')
147+
expect(html).toContain('"@type":"WebPage"')
148+
})
149+
150+
it('has BreadcrumbList JSON-LD', () => {
151+
const html = readFileSync(file, 'utf-8')
152+
expect(html).toContain('"@type":"BreadcrumbList"')
153+
expect(html).toContain('Dashboard')
154+
})
155+
})
156+
108157
describe('pre-rendered blog listing', () => {
109158
const file = join(distDir, 'blog', 'index.html')
110159

@@ -216,3 +265,55 @@ describe('pre-rendered blog post', () => {
216265
expect(html).toContain('"position":3')
217266
})
218267
})
268+
269+
describe('sitemap.xml', () => {
270+
const file = join(distDir, 'sitemap.xml')
271+
272+
it('exists', () => {
273+
expect(existsSync(file)).toBe(true)
274+
})
275+
276+
it('contains all pages', () => {
277+
const xml = readFileSync(file, 'utf-8')
278+
expect(xml).toContain('https://forkzero.ai/')
279+
expect(xml).toContain('https://forkzero.ai/getting-started')
280+
expect(xml).toContain('https://forkzero.ai/blog')
281+
expect(xml).toContain('https://forkzero.ai/privacy')
282+
})
283+
284+
it('contains all blog posts', () => {
285+
const xml = readFileSync(file, 'utf-8')
286+
for (const post of blogPosts) {
287+
expect(xml).toContain(`https://forkzero.ai/blog/${post.slug}`)
288+
}
289+
})
290+
291+
it('has lastmod dates', () => {
292+
const xml = readFileSync(file, 'utf-8')
293+
expect(xml).toMatch(/<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/)
294+
})
295+
})
296+
297+
describe('404.html', () => {
298+
const file = join(distDir, '404.html')
299+
300+
it('exists', () => {
301+
expect(existsSync(file)).toBe(true)
302+
})
303+
304+
it('has noindex meta tag', () => {
305+
const html = readFileSync(file, 'utf-8')
306+
expect(html).toContain('<meta name="robots" content="noindex"')
307+
})
308+
309+
it('has correct title', () => {
310+
const html = readFileSync(file, 'utf-8')
311+
expect(html).toContain('<title>Page not found — Forkzero</title>')
312+
})
313+
314+
it('has noscript fallback with link to homepage', () => {
315+
const html = readFileSync(file, 'utf-8')
316+
expect(html).toContain('<noscript>')
317+
expect(html).toContain('href="/"')
318+
})
319+
})

scripts/smoke.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Smoke tests against the live deployed site.
3+
*
4+
* Run manually after deploys:
5+
* npx vitest run scripts/smoke.test.ts
6+
*
7+
* These tests hit forkzero.ai over HTTPS and verify HTTP status codes,
8+
* redirects, headers, and basic content expectations.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest'
12+
import { blogPosts } from '../src/data/blog-posts.js'
13+
14+
const BASE = process.env.SITE_URL ?? 'https://forkzero.ai'
15+
16+
async function head(path: string, opts?: { redirect?: RequestRedirect }) {
17+
const res = await fetch(`${BASE}${path}`, {
18+
method: 'HEAD',
19+
redirect: opts?.redirect ?? 'manual',
20+
})
21+
return res
22+
}
23+
24+
async function get(path: string) {
25+
const res = await fetch(`${BASE}${path}`, { redirect: 'manual' })
26+
const text = await res.text()
27+
return { res, text }
28+
}
29+
30+
// --- HTTP status codes ---
31+
32+
describe('status codes', () => {
33+
it.each(['/', '/blog', '/getting-started', '/privacy', '/reader'])('%s returns 200', async (path) => {
34+
const res = await head(path)
35+
expect(res.status).toBe(200)
36+
})
37+
38+
it.each(blogPosts.map((p) => `/blog/${p.slug}`))('%s returns 200', async (path) => {
39+
const res = await head(path)
40+
expect(res.status).toBe(200)
41+
})
42+
43+
it('non-existent page returns 404', async () => {
44+
const res = await head('/this-page-does-not-exist-' + Date.now())
45+
expect(res.status).toBe(404)
46+
})
47+
})
48+
49+
// --- Trailing slash redirects ---
50+
51+
describe('trailing slash redirects', () => {
52+
it.each(['/blog/', '/getting-started/', '/privacy/', '/reader/'])('%s redirects to non-trailing', async (path) => {
53+
const res = await head(path)
54+
expect(res.status).toBe(301)
55+
const location = res.headers.get('location')
56+
expect(location).toBe(path.slice(0, -1))
57+
})
58+
})
59+
60+
// --- Security headers ---
61+
62+
describe('security headers', () => {
63+
it('has HSTS', async () => {
64+
const res = await head('/')
65+
expect(res.headers.get('strict-transport-security')).toContain('max-age=')
66+
})
67+
68+
it('has X-Frame-Options', async () => {
69+
const res = await head('/')
70+
expect(res.headers.get('x-frame-options')).toBe('DENY')
71+
})
72+
73+
it('has X-Content-Type-Options', async () => {
74+
const res = await head('/')
75+
expect(res.headers.get('x-content-type-options')).toBe('nosniff')
76+
})
77+
78+
it('has Referrer-Policy', async () => {
79+
const res = await head('/')
80+
expect(res.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin')
81+
})
82+
83+
it('has Permissions-Policy', async () => {
84+
const res = await head('/')
85+
expect(res.headers.get('permissions-policy')).toContain('geolocation=()')
86+
})
87+
})
88+
89+
// --- Content checks ---
90+
91+
describe('page content', () => {
92+
it('homepage has correct title', async () => {
93+
const { text } = await get('/')
94+
expect(text).toContain('<title>Lattice by Forkzero')
95+
})
96+
97+
it('homepage has JSON-LD', async () => {
98+
const { text } = await get('/')
99+
expect(text).toContain('application/ld+json')
100+
expect(text).toContain('"@type":"Organization"')
101+
})
102+
103+
it('blog post has BlogPosting schema', async () => {
104+
const { text } = await get(`/blog/${blogPosts[0].slug}`)
105+
expect(text).toContain('"@type":"BlogPosting"')
106+
})
107+
108+
it('404 page has noindex', async () => {
109+
const { text } = await get('/this-does-not-exist-' + Date.now())
110+
expect(text).toContain('content="noindex"')
111+
})
112+
113+
it('404 page has correct title', async () => {
114+
const { text } = await get('/this-does-not-exist-' + Date.now())
115+
expect(text).toContain('<title>Page not found')
116+
})
117+
})
118+
119+
// --- Static assets ---
120+
121+
describe('static assets', () => {
122+
it('robots.txt is accessible', async () => {
123+
const { res, text } = await get('/robots.txt')
124+
expect(res.status).toBe(200)
125+
expect(text).toContain('Sitemap:')
126+
})
127+
128+
it('sitemap.xml is accessible', async () => {
129+
const { res, text } = await get('/sitemap.xml')
130+
expect(res.status).toBe(200)
131+
expect(text).toContain('<urlset')
132+
expect(text).toContain('forkzero.ai')
133+
})
134+
135+
it('llms.txt is accessible', async () => {
136+
const { res, text } = await get('/llms.txt')
137+
expect(res.status).toBe(200)
138+
expect(text).toContain('Lattice')
139+
})
140+
141+
it('og-default.png is accessible', async () => {
142+
const res = await head('/og-default.png')
143+
expect(res.status).toBe(200)
144+
})
145+
146+
it('hashed assets have immutable cache', async () => {
147+
// Fetch homepage to find a hashed asset URL
148+
const { text } = await get('/')
149+
const match = text.match(/\/assets\/[^"]+\.js/)
150+
expect(match).not.toBeNull()
151+
152+
const res = await head(`${match![0]}`)
153+
expect(res.status).toBe(200)
154+
expect(res.headers.get('cache-control')).toContain('immutable')
155+
})
156+
})

0 commit comments

Comments
 (0)