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
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
102 lines
3.9 KiB
TypeScript
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();
|
|
});
|