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 1a613c0..1769e1a 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", "format": "prettier --write .", "format:check": "prettier --check .", @@ -31,6 +32,7 @@ "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@fontsource/space-grotesk": "^5.2.10", + "@playwright/test": "1.49.1", "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4ec15ca --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm preview', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 717d921..beb999d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@fontsource/space-grotesk': specifier: ^5.2.10 version: 5.2.10 + '@playwright/test': + specifier: 1.49.1 + version: 1.49.1 '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -627,6 +630,11 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -1415,6 +1423,11 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1772,6 +1785,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2633,6 +2656,10 @@ snapshots: '@oxc-project/types@0.124.0': {} + '@playwright/test@1.49.1': + dependencies: + playwright: 1.49.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -3323,6 +3350,9 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3631,6 +3661,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.49.1: {} + + playwright@1.49.1: + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} 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 96c7183..c25d46c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,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( + +