Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Failing after 27m51s
CI / Lint, typecheck, test (push) Successful in 3m11s
CI / Auth e2e pack (push) Successful in 4m0s
CI / Import/export fidelity gate (push) Failing after 36s
CI / Build container images (push) Has been skipped
Make the "structure-true best effort" fidelity contract (ADR 0009) an objective, pipeline-gated suite so "best effort" cannot erode silently. - New CI job "Import/export fidelity gate" (.gitea/workflows/ci.yml) runs the corpus suites against the pinned sidecar images the stages use (pandoc/core:3.6, gotenberg/gotenberg:8), started via docker run and reached over the host gateway. Small and separate so it stays well under five minutes; the suites self-skip in the main checks job (no sidecars). - Export fidelity: fixtures/export corpus + gen-export-fixtures.mjs + export.fidelity.test.ts — exports Markdown to docx/odt through the real pinned pandoc and reads it back, snapshotting the round trip so a writer drift (ours or a version bump) fails the gate. - PDF smoke: pdf.fidelity.test.ts renders a page through real Gotenberg and asserts the extracted text and a sane page count (pdf-parse, dev-only). - Fidelity contract doc: fixtures/README.md defines "corpus green = fidelity acceptable" and the fixture-first bug process; per-corpus READMEs updated. Because the snapshots are byte-exact and generated with the pinned tools, bumping a sidecar without regenerating shifts the output and fails the suite (AC3). The import corpus (#63) is folded into the same gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
74 lines
3.1 KiB
TypeScript
74 lines
3.1 KiB
TypeScript
import { DEFAULT_FONTS, PondFonts } from '@dorfteich/shared';
|
|
import { PDFParse } from 'pdf-parse';
|
|
import { beforeAll, describe, expect, it, TestContext } from 'vitest';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
|
|
import { GotenbergHttpRenderer } from './gotenberg.renderer';
|
|
import { buildPdfHtml } from './pdf-html';
|
|
|
|
/**
|
|
* PDF export smoke check (issue #69, ADR 0009): renders a known page to PDF
|
|
* through the real pinned Gotenberg and asserts the output is a PDF whose
|
|
* extracted text carries the expected strings, with a sane page count. This
|
|
* catches a broken renderer or a template regression that unit tests (which use
|
|
* a fake renderer) cannot. Needs a reachable Gotenberg sidecar
|
|
* (`gotenberg/gotenberg:8`); the test skips itself when none is configured, and
|
|
* CI starts one and points `GOTENBERG_URL` at it.
|
|
*/
|
|
const GOTENBERG_URL = process.env.GOTENBERG_URL ?? 'http://localhost:3000';
|
|
|
|
const renderer = new GotenbergHttpRenderer({ env: { GOTENBERG_URL } } as unknown as AppConfig);
|
|
|
|
let reachable = false;
|
|
|
|
/** Extract the concatenated text and page count from PDF bytes. */
|
|
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> {
|
|
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
|
try {
|
|
const result = await parser.getText();
|
|
return { text: result.text, pages: result.total };
|
|
} finally {
|
|
await parser.destroy();
|
|
}
|
|
}
|
|
|
|
describe('PDF export smoke (real Gotenberg, issue #69)', () => {
|
|
beforeAll(async () => {
|
|
reachable = await renderer.reachable().catch(() => false);
|
|
});
|
|
|
|
it('renders a page to a PDF containing its text, with a sane page count', async (ctx: TestContext) => {
|
|
if (!reachable) ctx.skip();
|
|
const html = buildPdfHtml({
|
|
title: 'Pond Fidelity Report',
|
|
pondName: 'Fidelity Pond',
|
|
// A forced page break so we can assert multi-page sanity; the markers are
|
|
// distinctive strings we can look for in the extracted text.
|
|
bodyHtml:
|
|
'<p>The northern reeds have spread noticeably this season.</p>' +
|
|
'<div style="page-break-before: always"></div>' +
|
|
'<p>Recorded water temperature was fourteen degrees.</p>',
|
|
fonts: DEFAULT_FONTS as PondFonts,
|
|
// No inlined font faces — the render falls back to the system stack, which
|
|
// still produces selectable text (fonts are covered by the #66/#67 tests).
|
|
fontFaceCss: '',
|
|
});
|
|
|
|
const pdf = await renderer.renderHtmlToPdf(html);
|
|
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
|
|
|
|
const { text, pages } = await readPdf(pdf);
|
|
// Title + pond name (from the header) and both body markers survive to text.
|
|
expect(text).toContain('Pond Fidelity Report');
|
|
expect(text).toContain('Fidelity Pond');
|
|
expect(text).toContain('northern reeds');
|
|
expect(text).toContain('water temperature');
|
|
// Page-count sanity: the forced break must produce a second page (≥ 2), and
|
|
// this trivial document must not balloon (≤ 3) — a runaway render from a CSS
|
|
// regression would blow well past that.
|
|
expect(pages).toBeGreaterThanOrEqual(2);
|
|
expect(pages).toBeLessThanOrEqual(3);
|
|
});
|
|
});
|