dorfteich/apps/web/e2e/export.spec.ts
Claude Opus 4.8 8a68ef68e7
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
Add PDF export via Gotenberg (#67)
Server-side PDF export for reading/sharing (ADR 0009), rendered by a new
internal Gotenberg (headless Chromium) sidecar.

- Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned,
  healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at
  warning-level (mirrors the converter) so PDF export degrades gracefully when
  Gotenberg is down without failing readyz.
- Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome)
  — the page's content with images inlined as data URIs, the pond's fonts
  inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print
  CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's
  footer. Plugin-block fallbacks are a marked TODO(#79) for M7.
- Fonts in the api image: the api Dockerfile now bakes the font catalog in
  (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen
  WOFF2 and inline them; a missing file falls back to the system stack.
- Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read
  permission checked by the guard) and enqueues an `export_pdf` job on the #62
  queue with the HTML as input; the worker branches `to === 'pdf'` to the
  `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable
  sidecar and failing a refused render (`renderer_unavailable`/`render_failed`,
  de+en). The client polls and downloads `GET /jobs/:id/result`.
- Frontend: the page-menu PDF button is now a real export (PDF added to
  EXPORT_FORMATS; the disabled placeholder removed).
- Tests: export.service.db PDF cases (HTML has title/font-variable/inlined
  image; renderer-down fails with `render_failed`); e2e PDF export self-skips
  without a Gotenberg sidecar (like the .docx case). Verified locally against
  real Gotenberg — a valid PDF with the pond font embedded.

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

85 lines
3.4 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Export pack (issue #65): the pond-settings ZIP download and the page-menu
* office-format export. The ZIP needs no conversion sidecar; `.docx` runs a
* conversion job and self-skips without `E2E_PANDOC` (CI's e2e stack has no
* reachable pandoc — same as the import pack, #64). Selectors are
* language-neutral (CSS classes, not button text).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
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 };
}
test('downloads a pond as a Markdown ZIP from pond settings', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
// Make sure the pond has at least one page to export.
await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Export Me ${Date.now()}` },
});
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('.pond-export a').click(),
]);
expect(download.suggestedFilename()).toBe(`${pond.slug}.zip`);
await context.close();
});
test('exports a page to .docx from the page menu', async ({ browser }) => {
test.skip(!process.env.E2E_PANDOC, 'needs a reachable pandoc sidecar');
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Docx Export ${Date.now()}` },
});
const created_page = await created.json();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
// The first export button is `.docx`; clicking runs the job and downloads it.
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30000 }),
page.locator('.editor-page__export button').first().click(),
]);
expect(download.suggestedFilename()).toBe(`${created_page.slug}.docx`);
await context.close();
});
test('exports a page to PDF from the page menu', async ({ browser }) => {
// PDF needs the Gotenberg sidecar reachable by the api (like the .docx case
// needs pandoc). CI's e2e stack has none, so this runs locally / on a stage
// with E2E_GOTENBERG set.
test.skip(!process.env.E2E_GOTENBERG, 'needs a reachable Gotenberg sidecar');
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Pdf Export ${Date.now()}` },
});
const created_page = await created.json();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
// The third export button is PDF (docx, odt, pdf).
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30000 }),
page.locator('.editor-page__export button').nth(2).click(),
]);
expect(download.suggestedFilename()).toBe(`${created_page.slug}.pdf`);
await context.close();
});