Add import/export fidelity gate to CI (#69)
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
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
This commit is contained in:
parent
462eca9699
commit
aaa9a253ae
@ -336,6 +336,80 @@ jobs:
|
||||
if: failure()
|
||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||
|
||||
# The import/export fidelity gate (issue #69, ADR 0009): runs the corpus
|
||||
# snapshot suites and the PDF smoke check against the *pinned* sidecar images
|
||||
# (the same versions the stages run), so a structural regression — ours or a
|
||||
# pandoc/Gotenberg version bump that drifts the output — fails the pipeline
|
||||
# instead of degrading "best effort" silently. Kept a small, separate job so
|
||||
# it stays well under five minutes; the suites self-skip in the main `checks`
|
||||
# job (no sidecars there).
|
||||
fidelity:
|
||||
name: Import/export fidelity gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The fidelity tests import from @dorfteich/shared's built dist; they run
|
||||
# TypeScript directly (vitest) so only shared needs building, not the api.
|
||||
- name: Build shared package
|
||||
run: pnpm --filter @dorfteich/shared build
|
||||
|
||||
# The import corpus test reaches through import.service, which imports the
|
||||
# generated Prisma client — generate it (no DB or migration needed; these
|
||||
# suites never touch a database).
|
||||
- name: Generate Prisma client
|
||||
run: pnpm --filter @dorfteich/api exec prisma generate
|
||||
|
||||
# Start the pinned sidecars with `docker run` (pandoc-server needs the
|
||||
# `server` arg, which Actions `services:` cannot pass). Their published
|
||||
# ports are reached from this job container via the Docker host gateway —
|
||||
# robust regardless of the runner's per-job network name. Uncommon host
|
||||
# ports avoid colliding with anything else on the runner.
|
||||
- name: Start pinned pandoc + Gotenberg sidecars
|
||||
run: |
|
||||
docker run -d --name fidelity-pandoc -p 13030:3030 pandoc/core:3.6 server
|
||||
docker run -d --name fidelity-gotenberg -p 13000:3000 gotenberg/gotenberg:8
|
||||
GW=$(ip -4 route show default | awk '{print $3; exit}')
|
||||
echo "SIDECAR_HOST=$GW" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "http://$GW:13030/version" >/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "http://$GW:13000/health" >/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
curl -sf "http://$GW:13030/version"
|
||||
curl -sf "http://$GW:13000/health"
|
||||
|
||||
- name: Run fidelity suite (import + export snapshots, PDF smoke)
|
||||
run: |
|
||||
PANDOC_URL="http://${SIDECAR_HOST}:13030" \
|
||||
GOTENBERG_URL="http://${SIDECAR_HOST}:13000" \
|
||||
pnpm --filter @dorfteich/api exec vitest run \
|
||||
src/import-export/import.fixtures.test.ts \
|
||||
src/import-export/export.fidelity.test.ts \
|
||||
src/import-export/pdf.fidelity.test.ts
|
||||
|
||||
- name: Dump sidecar logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
|
||||
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
|
||||
|
||||
images:
|
||||
name: Build container images
|
||||
# PR-only: on main the CD workflow builds and pushes the same images —
|
||||
|
||||
@ -11,3 +11,6 @@ apps/api/prisma/fixtures/content-page.md
|
||||
# Markdown/HTML opinions would break the regression tests.
|
||||
fixtures/import/*.expected.md
|
||||
fixtures/import/*.src.html
|
||||
# Export fidelity corpus (issue #69): the round-trip snapshots must stay exactly
|
||||
# as pandoc reads the exported document back — Prettier would break them.
|
||||
fixtures/export/*.expected.md
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
|
||||
73
apps/api/src/import-export/export.fidelity.test.ts
Normal file
73
apps/api/src/import-export/export.fidelity.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
73
apps/api/src/import-export/pdf.fidelity.test.ts
Normal file
73
apps/api/src/import-export/pdf.fidelity.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
70
fixtures/README.md
Normal file
70
fixtures/README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Import/export fidelity corpus (issue #69, ADR 0009)
|
||||
|
||||
The fidelity contract for document conversion is **structure-true best effort**:
|
||||
headings, paragraphs, lists, tables, images, links, and inline emphasis are
|
||||
preserved; layout (columns, text boxes, exact spacing) is explicitly out of
|
||||
scope (ADR 0009). "Best effort" only stays meaningful if it is executable, so
|
||||
this corpus **is** the contract:
|
||||
|
||||
> **The corpus is green ⇒ current fidelity is acceptable.**
|
||||
|
||||
The gate runs in CI (`.gitea/workflows/ci.yml`, job _Import/export fidelity
|
||||
gate_) against the **pinned** sidecar images the stages run — `pandoc/core:3.6`
|
||||
and `gotenberg/gotenberg:8`. It is deliberately a small, separate job that stays
|
||||
well under five minutes; the same suites self-skip in the main `checks` job,
|
||||
which has no sidecars.
|
||||
|
||||
## What the gate covers
|
||||
|
||||
| Suite | File | What it pins |
|
||||
| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Import snapshots | `apps/api/src/import-export/import.fixtures.test.ts` | `.docx`/`.odt` → Markdown for the [`import/`](./import) corpus |
|
||||
| Export snapshots | `apps/api/src/import-export/export.fidelity.test.ts` | Markdown → `.docx`/`.odt` → Markdown round trip for the [`export/`](./export) corpus |
|
||||
| PDF smoke | `apps/api/src/import-export/pdf.fidelity.test.ts` | a rendered PDF contains the expected text, with a sane page count |
|
||||
|
||||
Each corpus directory has its own README describing its files.
|
||||
|
||||
## The fixture-first bug process (the contract in practice)
|
||||
|
||||
A fidelity problem is not fixed by hand-patching output until one document looks
|
||||
right — that lets "best effort" quietly erode. Instead, **every fidelity bug
|
||||
enters the corpus first**:
|
||||
|
||||
1. **Reproduce it as a fixture.** Add the smallest document that exhibits the
|
||||
problem to the relevant corpus (`import/` a source document, `export/` a
|
||||
source `.md`). If the shape is new, extend an existing source rather than
|
||||
adding a near-duplicate.
|
||||
2. **Regenerate the snapshot** with the pinned sidecar so it records _today's_
|
||||
real output:
|
||||
- import: `PANDOC_URL=… node scripts/gen-import-fixtures.mjs`
|
||||
- export: `PANDOC_URL=… node scripts/gen-export-fixtures.mjs`
|
||||
(start the sidecar with `docker run --rm -p 3030:3030 pandoc/core:3.6 server`).
|
||||
3. **Commit the fixture and its snapshot together**, and review the snapshot
|
||||
diff — it is the human-readable statement of what the conversion does. If the
|
||||
snapshot encodes the _bug_, fix the pipeline and regenerate until the diff
|
||||
shows the intended structure; if it encodes a genuine pinned-tool limitation
|
||||
(see below), the snapshot _is_ the accepted behaviour and the fix is
|
||||
documentation, not code.
|
||||
4. **The gate now defends it.** Any later regression — ours or a tool's —
|
||||
changes that document's output and fails the suite.
|
||||
|
||||
## Why pinning matters (and how a version bump fails the gate)
|
||||
|
||||
The snapshots are byte-exact and generated with the pinned sidecar versions.
|
||||
pandoc changes its writers between releases (3.10 wraps lists and pads tables
|
||||
differently from 3.6), so **bumping `pandoc/core` — or `gotenberg/gotenberg` —
|
||||
without regenerating shifts the output and fails the suite.** That is the point:
|
||||
a version change becomes a reviewed snapshot diff, never a silent drift. To bump
|
||||
a sidecar: change the pin in `deploy/compose/docker-compose.yml`, the CI job, and
|
||||
these tests' defaults; regenerate the corpus; review the diff; commit together.
|
||||
|
||||
## Known pinned-tool limitations (accepted, not bugs)
|
||||
|
||||
- **ODT loses image alt text and table header rows**: pandoc's HTML→ODT _writer_
|
||||
does not encode either, so the ODT sources genuinely lack them (the DOCX
|
||||
variants keep both).
|
||||
- **ODT loosens tight lists and flattens fenced code blocks** to plain
|
||||
paragraphs on the export round trip; DOCX preserves both.
|
||||
|
||||
These are recorded in the corpus snapshots on purpose — extend the corpus rather
|
||||
than chasing layout parity.
|
||||
41
fixtures/export/README.md
Normal file
41
fixtures/export/README.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Export fixture corpus (issue #69, ADR 0009)
|
||||
|
||||
Source Markdown documents and the Markdown our export produces after a full
|
||||
**export → re-read round trip** through the pinned pandoc. `export.fidelity.test.ts`
|
||||
exports each source to `.docx`/`.odt` exactly as `ExportService` does
|
||||
(`gfm → <ext>`, standalone), reads the document back (`<ext> → gfm`), and asserts
|
||||
the result matches its snapshot — so a change in pandoc's office _writer_ (ours
|
||||
or a version bump) surfaces as a snapshot diff to review, not a silent
|
||||
regression. See [`../README.md`](../README.md) for the fidelity contract and the
|
||||
fixture-first bug process.
|
||||
|
||||
## Files
|
||||
|
||||
For each source `<name>.md` and format `<ext>` (`docx`, `odt`):
|
||||
|
||||
- `<name>.md` — the source Markdown (the shape a page's cached Markdown has).
|
||||
- `<name>.<ext>.expected.md` — the Markdown read back after exporting to `<ext>`.
|
||||
|
||||
`article` covers headings, **bold**/_italic_, a link, a nested bullet list, and
|
||||
an ordered list. `formatting` covers strikethrough, inline code, a blockquote,
|
||||
and a fenced code block.
|
||||
|
||||
## Fidelity notes (structure, not layout — ADR 0009)
|
||||
|
||||
The round-trip snapshots record genuine pinned-pandoc writer behaviour, not
|
||||
bugs:
|
||||
|
||||
- **ODT loosens tight lists** (blank lines appear between items) where DOCX keeps
|
||||
them tight.
|
||||
- **ODT flattens a fenced code block** to a plain paragraph; DOCX preserves it as
|
||||
code. Extend the corpus rather than chasing these.
|
||||
|
||||
## Regenerating
|
||||
|
||||
Generated with the **pinned** `pandoc/core:3.6` (the production sidecar) so the
|
||||
snapshots match CI:
|
||||
|
||||
```sh
|
||||
docker run --rm -p 3030:3030 pandoc/core:3.6 server
|
||||
PANDOC_URL=http://localhost:3030 node scripts/gen-export-fixtures.mjs
|
||||
```
|
||||
12
fixtures/export/article.docx.expected.md
Normal file
12
fixtures/export/article.docx.expected.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Field Notes
|
||||
|
||||
A short **survey** of the *village pond*, with a [reference](https://example.org/ponds).
|
||||
|
||||
## Observations
|
||||
|
||||
- Frogs at the north edge
|
||||
- Two clutches of spawn
|
||||
- Reeds spreading west
|
||||
|
||||
1. Measure depth
|
||||
2. Log temperature
|
||||
12
fixtures/export/article.md
Normal file
12
fixtures/export/article.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Field Notes
|
||||
|
||||
A short **survey** of the _village pond_, with a [reference](https://example.org/ponds).
|
||||
|
||||
## Observations
|
||||
|
||||
- Frogs at the north edge
|
||||
- Two clutches of spawn
|
||||
- Reeds spreading west
|
||||
|
||||
1. Measure depth
|
||||
2. Log temperature
|
||||
14
fixtures/export/article.odt.expected.md
Normal file
14
fixtures/export/article.odt.expected.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Field Notes
|
||||
|
||||
A short **survey** of the *village pond*, with a [reference](https://example.org/ponds).
|
||||
|
||||
## Observations
|
||||
|
||||
- Frogs at the north edge
|
||||
|
||||
- Two clutches of spawn
|
||||
|
||||
- Reeds spreading west
|
||||
|
||||
1. Measure depth
|
||||
2. Log temperature
|
||||
7
fixtures/export/formatting.docx.expected.md
Normal file
7
fixtures/export/formatting.docx.expected.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Formatting
|
||||
|
||||
Some ~~struck~~ text and `inline code`.
|
||||
|
||||
> A quoted remark about the water level.
|
||||
|
||||
`sample = measure(pond)`
|
||||
9
fixtures/export/formatting.md
Normal file
9
fixtures/export/formatting.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Formatting
|
||||
|
||||
Some ~~struck~~ text and `inline code`.
|
||||
|
||||
> A quoted remark about the water level.
|
||||
|
||||
```
|
||||
sample = measure(pond)
|
||||
```
|
||||
7
fixtures/export/formatting.odt.expected.md
Normal file
7
fixtures/export/formatting.odt.expected.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Formatting
|
||||
|
||||
Some ~~struck~~ text and `inline code`.
|
||||
|
||||
> A quoted remark about the water level.
|
||||
|
||||
sample = measure(pond)
|
||||
@ -4,7 +4,9 @@ Representative `.docx`/`.odt` documents and the Markdown our import pipeline is
|
||||
expected to produce from them. `import.fixtures.test.ts` runs the real two-pass
|
||||
pandoc conversion over each and asserts the result, so a change in behaviour
|
||||
(ours or pandoc's) surfaces as a snapshot diff to review — not a silent
|
||||
regression.
|
||||
regression. This suite is part of the CI **fidelity gate** (issue #69); see
|
||||
[`../README.md`](../README.md) for the fidelity contract and the fixture-first
|
||||
bug process (how a fidelity bug becomes a new corpus fixture).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
133
pnpm-lock.yaml
generated
133
pnpm-lock.yaml
generated
@ -144,6 +144,9 @@ importers:
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
pino-pretty:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.3
|
||||
@ -1663,6 +1666,75 @@ packages:
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@nestjs/cli@11.0.23':
|
||||
resolution: {integrity: sha512-2V0Bf5jz0KXhUZk3eJi9GljIyqH04otwsE/mYLbqJR+X0iiYx+6bkNJ2Qz28uHNFj1cpHgimf9xDzHkqarie0g==}
|
||||
engines: {node: '>= 20.11'}
|
||||
@ -4111,6 +4183,15 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
|
||||
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@ -6726,6 +6807,49 @@ snapshots:
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.80
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.80
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.80
|
||||
|
||||
'@nestjs/cli@11.0.23(@swc/core@1.15.43)(@types/node@26.1.0)(prettier@3.9.4)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 19.2.27(chokidar@4.0.3)
|
||||
@ -9334,6 +9458,15 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
pdfjs-dist: 5.4.296
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
|
||||
54
scripts/gen-export-fixtures.mjs
Normal file
54
scripts/gen-export-fixtures.mjs
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerate the export fidelity corpus (issue #69, ADR 0009) from the committed
|
||||
// `*.md` sources: for each source and each office format, write the Markdown you
|
||||
// get back after a full export → re-read round trip through the pinned pandoc.
|
||||
// That round-trip snapshot is what the fidelity gate pins — a change in pandoc's
|
||||
// docx/odt writer (e.g. a version bump) shifts the output and fails the suite.
|
||||
//
|
||||
// docker run --rm -p 3030:3030 pandoc/core:3.6 server
|
||||
// PANDOC_URL=http://localhost:3030 node scripts/gen-export-fixtures.mjs
|
||||
//
|
||||
// Mirrors the export path (ExportService: gfm → <ext>, standalone) and then
|
||||
// reads the document back (<ext> → gfm) exactly as export.fidelity.test.ts does.
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'export');
|
||||
const PANDOC_URL = process.env.PANDOC_URL ?? 'http://localhost:3030';
|
||||
// Match the import corpus reader so both directions normalise the same way.
|
||||
const MARKDOWN_FORMAT = 'gfm-implicit_figures-raw_html';
|
||||
const BINARY = new Set(['docx', 'odt']);
|
||||
|
||||
async function pandoc(params) {
|
||||
const res = await fetch(`${PANDOC_URL}/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!res.ok) throw new Error(`pandoc ${res.status}: ${await res.text()}`);
|
||||
return BINARY.has(params.to) ? Buffer.from(await res.arrayBuffer()) : res.text();
|
||||
}
|
||||
|
||||
/** Export markdown to an office document, then read it back to markdown. */
|
||||
async function roundTrip(markdown, ext) {
|
||||
const document = await pandoc({ text: markdown, from: 'gfm', to: ext, standalone: true });
|
||||
return pandoc({
|
||||
text: document.toString('base64'),
|
||||
from: ext,
|
||||
to: MARKDOWN_FORMAT,
|
||||
standalone: false,
|
||||
wrap: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
const sources = readdirSync(FIXTURES).filter((f) => f.endsWith('.md') && !f.includes('.expected.'));
|
||||
for (const source of sources) {
|
||||
const name = source.replace(/\.md$/, '');
|
||||
const markdown = readFileSync(join(FIXTURES, source), 'utf8');
|
||||
for (const ext of ['docx', 'odt']) {
|
||||
writeFileSync(join(FIXTURES, `${name}.${ext}.expected.md`), await roundTrip(markdown, ext));
|
||||
console.log(`wrote ${name}.${ext}.expected.md`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user