dorfteich/apps/api/src/import-export/export.fidelity.test.ts
Claude Opus 4.8 aaa9a253ae
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
Add import/export fidelity gate to CI (#69)
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
2026-07-10 13:59:10 +02:00

74 lines
3.0 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { beforeAll, describe, expect, it, TestContext } from 'vitest';
import { AppConfig } from '../config/app-config.service';
import { markdownForDocument } from './export-markdown';
import { PandocServerConverter } from './pandoc.converter';
/**
* Export fidelity regression (issue #69, ADR 0009): exports the committed
* Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads
* each document back, asserting the round-trip Markdown matches its snapshot.
* This gates the export writer's structural fidelity — a pandoc version bump
* that shifts the office output surfaces as a snapshot diff to review, not a
* silent regression. Needs a reachable pandoc sidecar (`pandoc/core:3.6`, so
* output matches the snapshots); each test skips itself when none is
* configured, and CI starts one and points `PANDOC_URL` at it.
*/
const PANDOC_URL = process.env.PANDOC_URL ?? 'http://localhost:3030';
// The runner's cwd is `apps/api`; the corpus lives at the repo root.
const FIXTURES = join(process.cwd(), '../../fixtures/export');
// Same reader options as the import corpus, so both directions normalise alike.
const MARKDOWN_FORMAT = 'gfm-implicit_figures-raw_html';
const converter = new PandocServerConverter({ env: { PANDOC_URL } } as unknown as AppConfig);
let reachable = false;
const CORPUS = ['article', 'formatting'];
const FORMATS = ['docx', 'odt'] as const;
/** Export `markdown` to an office document, then read it back to Markdown —
* exactly the ExportService write path (gfm → ext, standalone) followed by a
* re-read (ext → gfm). */
async function roundTrip(markdown: string, ext: 'docx' | 'odt'): Promise<string> {
const document = await converter.convert({
from: 'gfm',
to: ext,
input: Buffer.from(markdown, 'utf8'),
standalone: true,
});
const back = await converter.convert({
from: ext,
to: MARKDOWN_FORMAT,
input: document.output,
standalone: false,
wrap: 'none',
});
return back.output.toString('utf8');
}
describe('export fidelity corpus (real pandoc, issue #69)', () => {
beforeAll(async () => {
reachable = await converter.reachable().catch(() => false);
});
for (const name of CORPUS) {
for (const ext of FORMATS) {
it(`round-trips ${name}.md through ${ext} to its expected Markdown`, async (ctx: TestContext) => {
if (!reachable) ctx.skip();
const source = readFileSync(join(FIXTURES, `${name}.md`), 'utf8');
// The export transform (flatten wikilinks, inline images) runs first; for
// this text corpus it is an identity, so the snapshot isolates pandoc's
// office-writer fidelity — the part that drifts on a version bump.
const document = markdownForDocument(source, new Map());
const expected = readFileSync(join(FIXTURES, `${name}.${ext}.expected.md`), 'utf8');
expect(await roundTrip(document, ext)).toBe(expected);
});
}
}
});