Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions e2e/newsletter.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions scripts/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
// Lazy load pages
const Faq = lazy(() => import('./pages/Faq'));
const Privacy = lazy(() => import('./pages/Privacy'));
const Newsletter = lazy(() => import('./pages/Newsletter'));

Check failure on line 26 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build

'Newsletter' is declared but its value is never read.

Check failure on line 26 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

'Newsletter' is declared but its value is never read.
const UseCases = lazy(() => import('./pages/UseCases'));
const Stellar = lazy(() => import('./pages/Stellar'));
const Roadmap = lazy(() => import('./pages/Roadmap'));
Expand Down
Loading
Loading