Skip to content

Commit 2bf99aa

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 2bf99aa

14 files changed

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

e2e/fixtures/entry-client.tsx

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

e2e/fixtures/entry-server.tsx

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

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

e2e/playwright.config.ts

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

0 commit comments

Comments
 (0)