Skip to content

Commit aa23ac8

Browse files
committed
test: add e2e tests for server and browser
- SSR e2e (vitest, Node environment): renders pages with renderToString and verifies helmet context output for title, meta, link, base, style, script, noscript, html/body attributes, titleTemplate, nested components - Browser e2e (Playwright + Vite dev server): navigates to pages in a real browser and verifies tags appear in <head>, html/body attributes are set, title is correct, nested components resolve correctly - Shared fixture app with 4 pages: meta (declarative), title-template, api (prop-style), nested (multiple Helmet instances) - CI: SSR e2e runs in the build matrix, browser e2e runs as a separate job with Playwright chromium Scripts: test:e2e:server — SSR tests via vitest test:e2e:browser — browser tests via Playwright test:e2e — both test:all — unit + e2e
1 parent 09052d3 commit aa23ac8

14 files changed

Lines changed: 541 additions & 6 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,31 @@ jobs:
3434
- name: Lint
3535
run: pnpm lint
3636

37-
- name: Tests
37+
- name: Unit tests
3838
run: pnpm test
39+
40+
- name: SSR E2E tests
41+
run: pnpm run test:e2e:server
42+
43+
e2e-browser:
44+
runs-on: ubuntu-latest
45+
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- uses: pnpm/action-setup@v4
50+
with:
51+
version: latest
52+
53+
- uses: actions/setup-node@v4
54+
with:
55+
node-version: 22
56+
cache: pnpm
57+
58+
- run: pnpm install --frozen-lockfile
59+
60+
- name: Install Playwright browsers
61+
run: pnpm exec playwright install --with-deps chromium
62+
63+
- name: Browser E2E tests
64+
run: pnpm run test:e2e:browser

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
lib
22
node_modules
3+
test-results
4+
playwright-report

e2e/browser.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Browser E2E — Declarative meta page', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/?page=meta');
6+
await page.waitForSelector('#page-indicator');
7+
});
8+
9+
test('sets document title', async ({ page }) => {
10+
await expect(page).toHaveTitle('E2E Test Page');
11+
});
12+
13+
test('renders meta charset', async ({ page }) => {
14+
const charset = page.locator('head meta[charset]');
15+
await expect(charset).toHaveAttribute('charset', 'utf-8');
16+
});
17+
18+
test('renders meta description', async ({ page }) => {
19+
const desc = page.locator('head meta[name="description"]');
20+
await expect(desc).toHaveAttribute('content', 'E2E test description');
21+
});
22+
23+
test('renders og:title meta', async ({ page }) => {
24+
const og = page.locator('head meta[property="og:title"]');
25+
await expect(og).toHaveAttribute('content', 'E2E OG Title');
26+
});
27+
28+
test('renders canonical link', async ({ page }) => {
29+
const link = page.locator('head link[rel="canonical"]');
30+
await expect(link).toHaveAttribute('href', 'https://example.com/e2e');
31+
});
32+
33+
test('renders stylesheet link', async ({ page }) => {
34+
const link = page.locator('head link[rel="stylesheet"]');
35+
await expect(link).toHaveAttribute('href', '/test.css');
36+
});
37+
38+
test('renders base tag', async ({ page }) => {
39+
const base = page.locator('head base');
40+
await expect(base).toHaveAttribute('href', 'https://example.com/');
41+
});
42+
43+
test('renders inline style', async ({ page }) => {
44+
const style = page.locator('head style');
45+
const text = await style.textContent();
46+
expect(text).toContain('background: red');
47+
});
48+
49+
test('renders inline script', async ({ page }) => {
50+
const script = page.locator('head script[type="application/ld+json"]');
51+
const text = await script.textContent();
52+
expect(text).toContain('"@context"');
53+
});
54+
55+
test('sets html attributes', async ({ page }) => {
56+
const lang = await page.locator('html').getAttribute('lang');
57+
expect(lang).toBe('en');
58+
const cls = await page.locator('html').getAttribute('class');
59+
expect(cls).toContain('e2e-html');
60+
});
61+
62+
test('sets body attributes', async ({ page }) => {
63+
const cls = await page.locator('body').getAttribute('class');
64+
expect(cls).toContain('e2e-body');
65+
const dataPage = await page.locator('body').getAttribute('data-page');
66+
expect(dataPage).toBe('meta');
67+
});
68+
});
69+
70+
test.describe('Browser E2E — Title template page', () => {
71+
test('applies titleTemplate', async ({ page }) => {
72+
await page.goto('/?page=title-template');
73+
await page.waitForSelector('#title-template-content');
74+
await expect(page).toHaveTitle('Site Name - Templated');
75+
});
76+
});
77+
78+
test.describe('Browser E2E — API props page', () => {
79+
test.beforeEach(async ({ page }) => {
80+
await page.goto('/?page=api');
81+
await page.waitForSelector('#page-indicator');
82+
});
83+
84+
test('sets title via prop', async ({ page }) => {
85+
await expect(page).toHaveTitle('API Title');
86+
});
87+
88+
test('sets meta via prop array', async ({ page }) => {
89+
const robots = page.locator('head meta[name="robots"]');
90+
await expect(robots).toHaveAttribute('content', 'noindex');
91+
92+
const ogUrl = page.locator('head meta[property="og:url"]');
93+
await expect(ogUrl).toHaveAttribute('content', 'https://example.com/api');
94+
});
95+
96+
test('sets link via prop array', async ({ page }) => {
97+
const link = page.locator('head link[rel="canonical"]');
98+
await expect(link).toHaveAttribute('href', 'https://example.com/api');
99+
});
100+
101+
test('sets html lang via htmlAttributes', async ({ page }) => {
102+
const lang = await page.locator('html').getAttribute('lang');
103+
expect(lang).toBe('fr');
104+
});
105+
106+
test('sets body class via bodyAttributes', async ({ page }) => {
107+
const cls = await page.locator('body').getAttribute('class');
108+
expect(cls).toContain('api-body');
109+
});
110+
});
111+
112+
test.describe('Browser E2E — Nested Helmet components', () => {
113+
test.beforeEach(async ({ page }) => {
114+
await page.goto('/?page=nested');
115+
await page.waitForSelector('#page-indicator');
116+
});
117+
118+
test('innermost title wins', async ({ page }) => {
119+
await expect(page).toHaveTitle('Inner Title');
120+
});
121+
122+
test('innermost description wins', async ({ page }) => {
123+
const desc = page.locator('head meta[name="description"]');
124+
await expect(desc).toHaveAttribute('content', 'Inner description');
125+
});
126+
127+
test('keywords from inner component are present', async ({ page }) => {
128+
const kw = page.locator('head meta[name="keywords"]');
129+
await expect(kw).toHaveAttribute('content', 'inner,nested');
130+
});
131+
});

e2e/fixtures/App.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from 'react';
2+
3+
import { Helmet } from '../../src';
4+
5+
function MetaPage() {
6+
return (
7+
<Helmet>
8+
<title>E2E Test Page</title>
9+
<meta charSet="utf-8" />
10+
<meta name="description" content="E2E test description" />
11+
<meta property="og:title" content="E2E OG Title" />
12+
<link rel="canonical" href="https://example.com/e2e" />
13+
<link rel="stylesheet" href="/test.css" type="text/css" />
14+
<base href="https://example.com/" />
15+
<style type="text/css">{`body { background: red; }`}</style>
16+
<script type="application/ld+json">{`{"@context":"http://schema.org"}`}</script>
17+
<noscript>{`<link rel="stylesheet" href="/noscript.css" />`}</noscript>
18+
<html lang="en" className="e2e-html" />
19+
<body className="e2e-body" data-page="meta" />
20+
</Helmet>
21+
);
22+
}
23+
24+
function TitleTemplatePage() {
25+
return (
26+
<>
27+
<Helmet titleTemplate="Site Name - %s" defaultTitle="Site Name">
28+
<title>Templated</title>
29+
</Helmet>
30+
<div id="title-template-content">Title Template Page</div>
31+
</>
32+
);
33+
}
34+
35+
function ApiPage() {
36+
return (
37+
<Helmet
38+
title="API Title"
39+
meta={[
40+
{ name: 'robots', content: 'noindex' },
41+
{ property: 'og:url', content: 'https://example.com/api' },
42+
]}
43+
link={[{ rel: 'canonical', href: 'https://example.com/api' }]}
44+
>
45+
<html lang="fr" />
46+
<body className="api-body" />
47+
</Helmet>
48+
);
49+
}
50+
51+
function NestedPage() {
52+
return (
53+
<div>
54+
<Helmet>
55+
<title>Outer Title</title>
56+
<meta name="description" content="Outer description" />
57+
</Helmet>
58+
<div>
59+
<Helmet>
60+
<title>Inner Title</title>
61+
<meta name="description" content="Inner description" />
62+
<meta name="keywords" content="inner,nested" />
63+
</Helmet>
64+
</div>
65+
</div>
66+
);
67+
}
68+
69+
type Page = 'meta' | 'title-template' | 'api' | 'nested';
70+
71+
function getPage(): Page {
72+
if (typeof window !== 'undefined') {
73+
const params = new URLSearchParams(window.location.search);
74+
return (params.get('page') as Page) || 'meta';
75+
}
76+
return 'meta';
77+
}
78+
79+
export function App({ page: serverPage }: { page?: Page }) {
80+
const page = serverPage || getPage();
81+
return (
82+
<div id="app">
83+
{page === 'meta' && <MetaPage />}
84+
{page === 'title-template' && <TitleTemplatePage />}
85+
{page === 'api' && <ApiPage />}
86+
{page === 'nested' && <NestedPage />}
87+
<p id="page-indicator">{page}</p>
88+
</div>
89+
);
90+
}
91+
92+
export type { Page };
93+
export default App;

e2e/fixtures/entry-client.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
4+
import { HelmetProvider } from '../../src';
5+
6+
import { App } from './App';
7+
8+
createRoot(document.getElementById('root')!).render(
9+
<HelmetProvider>
10+
<App />
11+
</HelmetProvider>
12+
);

e2e/fixtures/entry-server.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
import { renderToString } from 'react-dom/server';
3+
4+
import { HelmetProvider } from '../../src';
5+
import type { HelmetServerState } from '../../src';
6+
7+
import { App } from './App';
8+
import type { Page } from './App';
9+
10+
export function renderPage(page: Page) {
11+
const helmetContext: { helmet?: HelmetServerState } = {};
12+
13+
const html = renderToString(
14+
<HelmetProvider context={helmetContext}>
15+
<App page={page} />
16+
</HelmetProvider>
17+
);
18+
19+
const { helmet } = helmetContext;
20+
21+
return { html, helmet };
22+
}

e2e/fixtures/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head></head>
4+
<body>
5+
<div id="root"></div>
6+
<script type="module" src="./entry-client.tsx"></script>
7+
</body>
8+
</html>

e2e/fixtures/vite.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import path from 'path';
2+
3+
import { defineConfig } from 'vite';
4+
import react from '@vitejs/plugin-react';
5+
6+
export default defineConfig({
7+
plugins: [react()],
8+
root: path.resolve(__dirname),
9+
server: {
10+
port: 3123,
11+
strictPort: true,
12+
},
13+
});

e2e/playwright.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import path from 'path';
2+
3+
import { defineConfig } from '@playwright/test';
4+
5+
export default defineConfig({
6+
testDir: '.',
7+
testMatch: /browser\.test\.ts/,
8+
timeout: 30_000,
9+
retries: 0,
10+
use: {
11+
baseURL: 'http://localhost:3123',
12+
headless: true,
13+
},
14+
webServer: {
15+
command: 'pnpm exec vite --config e2e/fixtures/vite.config.ts',
16+
port: 3123,
17+
cwd: path.resolve(__dirname, '..'),
18+
reuseExistingServer: !process.env.CI,
19+
},
20+
});

0 commit comments

Comments
 (0)