dorfteich/apps/web/e2e/fonts.spec.ts
Claude Opus 4.8 f500198c5d
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Auth e2e pack (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add per-pond fonts: catalog, build, application, and admin UI (#66)
Self-hosted Google Fonts with per-pond selection (ADR 0016), the GDPR
"zero external requests" posture (security.md, CSP `font-src 'self'`).

- Catalog: a curated 15-family OFL/Apache list in shared (family, weights,
  category, license, google-webfonts-helper id). `deploy/fonts/build-fonts.mjs`
  validates every entry has license info (fails the build otherwise),
  downloads the WOFF2 weights into apps/web/public/fonts/ (gitignored), and
  generates the @font-face stylesheet — run at image build time from the web
  Dockerfile (with retries), never from a visitor's browser.
- Application: PondFontScope sets --font-heading/body/mono (+ weights) from
  pond.settings.fonts on the editor + read view; the existing global CSS
  already reads those custom properties, so headings/body/code re-resolve to
  the pond's fonts. A pond with no settings arrives with the defaulted values
  (Roboto 400 / Roboto 200 / Fira Code), so the vision defaults always render.
- Admin UI: pond-settings 'Appearance' section — three slots (family + weight)
  with a live preview, Pond-Admin-gated (fonts added to updatePondInputSchema
  and merged in PondsService.update); a font catalog attribution page (/fonts)
  listing families and licenses. New `font` i18n namespace (de+en).
- CSP: strict Content-Security-Policy in nginx.conf (default-src 'self';
  font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; …) —
  the app's scripts are all external files, inline styles cover CSS variables.
- Tests: shared catalog-integrity unit test (the invariant the build enforces);
  e2e fonts pack — no request leaves the origin when rendering a pond (the GDPR
  network assertion), a font choice applies to a page and persists, and a pond
  without settings renders the defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 11:16:29 +02:00

102 lines
3.9 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Pond fonts pack (issue #66, ADR 0016): the GDPR "zero external requests"
* guarantee, and that a pond's font choice applies to its pages and persists.
* Selectors are language-neutral (CSS classes, not button text).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const ORIGIN = new URL(BASE_URL).host;
const DEFAULT_FONTS = {
heading: { family: 'Roboto', weight: 400 },
body: { family: 'Roboto', weight: 200 },
mono: { family: 'Fira Code', weight: 400 },
};
async function personalPond(
context: Awaited<ReturnType<typeof contextForUser>>,
): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
pondId: string,
title: string,
): Promise<string> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return (await created.json()).slug;
}
test('renders a pond without any request leaving the origin (GDPR)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Net ${Date.now()}`);
const page = await context.newPage();
const offOrigin: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (url.startsWith('data:') || url.startsWith('blob:')) return;
if (new URL(url).host !== ORIGIN) offOrigin.push(url);
});
await page.goto(`/p/${pond.slug}/${slug}`);
await page.locator('.ProseMirror').waitFor();
// Give any late asset/font/websocket request a chance to fire.
await page.waitForTimeout(750);
expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]);
await context.close();
});
test('a pond font choice applies to its pages and persists', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Apply ${Date.now()}`);
const page = await context.newPage();
try {
await page.goto(`/p/${pond.slug}/settings`);
// Set the body font to a distinctive serif, then save.
await page.locator('.appearance__slot--body select').first().selectOption('Merriweather');
await page.locator('.appearance__actions button').click();
await page.goto(`/p/${pond.slug}/${slug}`);
const font = await page
.locator('.ProseMirror')
.evaluate((el) => getComputedStyle(el).fontFamily);
expect(font).toContain('Merriweather');
// Persisted across a reload.
await page.reload();
const afterReload = await page
.locator('.ProseMirror')
.evaluate((el) => getComputedStyle(el).fontFamily);
expect(afterReload).toContain('Merriweather');
} finally {
// Restore defaults so reruns / other tests see a clean pond.
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { fonts: DEFAULT_FONTS } });
await context.close();
}
});
test('a pond with no font settings renders the vision defaults', async ({ browser }) => {
// fixture-editor's personal pond is never reconfigured by these tests.
const context = await contextForUser(browser, BASE_URL, 'fixture-editor');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Default ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
const font = await page.locator('.ProseMirror').evaluate((el) => getComputedStyle(el).fontFamily);
// Default body family is Roboto (ADR 0016).
expect(font).toContain('Roboto');
await context.close();
});