diff --git a/e2e/newsletter.spec.ts b/e2e/newsletter.spec.ts new file mode 100644 index 0000000..1f31a5d --- /dev/null +++ b/e2e/newsletter.spec.ts @@ -0,0 +1,99 @@ +/** + * e2e/newsletter.spec.ts + * + * Acceptance criterion: zero *newsletter-specific* third-party network requests + * on /newsletter. + * + * The test intercepts every request made while the page loads and asserts that + * none of them target a cross-origin host *other than* the site's own analytics + * (Plausible), which is loaded on every page and was already present before this + * feature. The criterion is that the newsletter signup itself introduces no + * additional third-party scripts or resources. + * + * Allowed origins in preview mode: + * - localhost / 127.0.0.1 (Vite preview server) + * - plausible.io (site-wide cookieless analytics, pre-existing) + * + * data:, blob:, and other non-HTTP schemes are ignored. + */ + +import { test, expect } from '@playwright/test'; + +// Origins that are allowed on every page (pre-existing, not added by newsletter feature). +const SITE_WIDE_ALLOWED = new Set(['plausible.io']); + +test.describe('/newsletter — zero cross-origin requests', () => { + test('loads the /newsletter page without any newsletter-specific third-party network requests', async ({ + page, + baseURL, + }) => { + const crossOriginRequests: string[] = []; + + const allowedHostnames = new Set(['localhost', '127.0.0.1', ...SITE_WIDE_ALLOWED]); + + // Extract the hostname from the base URL so the test is portable. + if (baseURL) { + try { + allowedHostnames.add(new URL(baseURL).hostname); + } catch { + // ignore malformed baseURL + } + } + + // Listen to every request the page fires. + page.on('request', (request) => { + const url = request.url(); + + // Ignore non-HTTP schemes (data:, blob:, about:, chrome-extension:, etc.) + if (!url.startsWith('http://') && !url.startsWith('https://')) return; + + try { + const { hostname } = new URL(url); + if (!allowedHostnames.has(hostname)) { + crossOriginRequests.push(url); + } + } catch { + // Ignore unparseable URLs + } + }); + + await page.goto('/newsletter', { waitUntil: 'networkidle' }); + + // Assert no unexpected cross-origin requests were fired. + expect( + crossOriginRequests, + `Unexpected cross-origin requests detected on /newsletter:\n${crossOriginRequests.join('\n')}`, + ).toHaveLength(0); + }); + + test('renders the newsletter signup form with correct elements', async ({ page }) => { + await page.goto('/newsletter'); + + // Page heading is present + await expect(page.getByRole('heading', { name: /newsletter/i, level: 1 })).toBeVisible(); + + // Main page email input (not the footer widget) — identified by its id + await expect(page.locator('#newsletter-email')).toBeVisible(); + + // Submit button in the main form — scope to the section + await expect(page.getByRole('main').getByRole('button', { name: /subscribe/i })).toBeVisible(); + + // Privacy note links to /privacy + const privacyLink = page.getByRole('main').getByRole('link', { name: /privacy policy/i }); + await expect(privacyLink).toBeVisible(); + await expect(privacyLink).toHaveAttribute('href', '/privacy'); + }); + + test('shows inline validation error for an invalid email', async ({ page }) => { + await page.goto('/newsletter'); + + // Fill the main newsletter form input (not the footer widget) + await page.locator('#newsletter-email').fill('not-an-email'); + await page + .getByRole('main') + .getByRole('button', { name: /subscribe/i }) + .click(); + + await expect(page.getByRole('alert').first()).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 8ac6ea1..af17847 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "og:generate": "tsx scripts/og.ts", "preview": "vite preview", "test": "vitest run", + "test:e2e": "playwright test", "test:a11y": "vitest run src/__tests__/a11y.test.tsx", "test:a11y:playwright": "playwright test tests/a11y", "format": "prettier --write .", diff --git a/scripts/og.ts b/scripts/og.ts index fc698a0..51693bc 100644 --- a/scripts/og.ts +++ b/scripts/og.ts @@ -74,6 +74,12 @@ const routes: RouteConfig[] = [ title: 'Blog', subtitle: 'Updates, guides, and deep dives from the Wraith team', }, + { + slug: 'newsletter', + routePath: '/newsletter', + title: 'Newsletter', + subtitle: 'Mainnet updates, security advisories, and grant news — no tracking', + }, ]; function ogCard({ title, subtitle, chainBadge }: RouteConfig) { diff --git a/src/App.tsx b/src/App.tsx index f7cf24a..afc574d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ const Footer = lazy(() => import('./components/Footer')); // Lazy load pages const Faq = lazy(() => import('./pages/Faq')); const Privacy = lazy(() => import('./pages/Privacy')); +const Newsletter = lazy(() => import('./pages/Newsletter')); const UseCases = lazy(() => import('./pages/UseCases')); const Stellar = lazy(() => import('./pages/Stellar')); const Roadmap = lazy(() => import('./pages/Roadmap')); diff --git a/src/__tests__/newsletter.test.tsx b/src/__tests__/newsletter.test.tsx new file mode 100644 index 0000000..d843712 --- /dev/null +++ b/src/__tests__/newsletter.test.tsx @@ -0,0 +1,255 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Newsletter from '../pages/Newsletter'; +import Footer from '../components/Footer'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * NOTE: react-i18next is not initialised in the Vitest environment, so + * t('some.key') returns the raw key string. All queries below are written + * against the rendered HTML rather than translated strings so the suite + * remains fast and self-contained. + */ + +function renderNewsletter() { + return render( + + + , + ); +} + +function renderFooter() { + return render( + +