Pulp EngineDocument Rendering
Get started

Template test suites

Run dry-runs, document content assertions and snapshots before promoting a template. The harness supports stored versions and local definitions. CI and the editor enforce their own gates; the server does not require a passing test run for label promotion.

pulp-engine test ./tests
pulp-engine test ./tests --local ./templates --datasets-dir ./templates/datasets
pulp-engine test ./tests --update-snapshots
pulp-engine test ./tests --reporter html --artifacts-dir ./pulp-engine-test-results
pulp-engine test ./tests --reporter junit --output test-results.xml

--local without a directory uses the configured templates directory. It maps each fixture key to <dir>/<key>.json, sends the definition to preview routes without pushing it, and reports the version as local. An inline-route 404 explains PREVIEW_ROUTES_ENABLED=false. Render credentials suffice for pinned fixtures; shared dataset reads require editor or admin credentials. Exit codes remain 0 for success, 1 for test/fixture failure, and 2 for CLI configuration errors.

Fixtures

template: invoice
version: "1.0.0"
now: "2026-01-01T00:00:00Z"
tests:
  - name: shared scenarios
    input: {dataset: '*'}
    expect:
      dryRun: strict
      htmlSnapshot: __snapshots__/{dataset}.html
      pdfSnapshot:
        dir: __snapshots__/pdf/{dataset}
        pages: all
        thresholdPercent: 0.5
        pixelThreshold: 0.1
      pageCount: {min: 1, max: 3}
      contains: [Invoice, 're:Total\s+[0-9]+']
      notContains: [undefined]
      noUnresolvedBindings: true
      maxBytes: 500000
      tableRows: [{tableId: items, min: 1, max: 500}]
      docxSnapshot: __snapshots__/{dataset}.docx.txt
      xlsxSnapshot: __snapshots__/{dataset}.xlsx.json

Use templateFile: ../templates/invoice.json instead of version for an explicit local definition. Exactly one is required. Template files and input files must contain JSON objects. Input can be inline or {file: ./data.json}. The file and dataset forms are special only when they are the object’s sole key, so business data with a file property remains valid inline input.

input: {dataset: name} resolves one shared sample. '*' expands non-negative datasets into <case> [<dataset>] cases. {dataset} is substituted in snapshot paths. --datasets-dir reads the {meta,data} files created by pull --datasets without dataset API calls. Fixture and case now values pin @render.now; the harness otherwise uses 2026-01-01 for repeatable tests.

Expect option Behavior
dryRun ok (default), strict (also requires every binding), fail (negative test), or skip
htmlSnapshot Normalized HTML baseline file
pdfSnapshot Legacy first-page PNG string or multi-page object {dir,pages,thresholdPercent?,tolerancePixels?,pixelThreshold?}
pageCount Exact positive integer or {min?,max?} range
contains, notContains Literal text or re:<pattern> regex, checked after text normalization
textSource html (default) or optional pdf extraction
maxBytes Maximum PDF byte length
noUnresolvedBindings Fails on binding warnings or when an older server omits diagnostics
tableRows RFC 4180 CSV record count, minus the header, for each selected table
docxSnapshot Normalized DOCX outline baseline
xlsxSnapshot Normalized worksheet/cell JSON baseline

Every needed format renders at most once per case, including when several assertions need its output. Every content assertion runs and reports its failure. A failed dry-run stops that case before expensive rendering. HTML text strips tags, scripts and styles, decodes entities and collapses whitespace.

Multi-page PDF baselines and artifacts

Install optional raster dependencies in the test project:

npm install --save-dev pdf-to-png-converter pixelmatch pngjs
# Only needed for textSource: pdf:
npm install --save-dev pdfjs-dist

pages: all compares every page. An explicit list such as [1,3] rasterizes only those pages. Baseline directories contain page-001.png, page-002.png, and a .pages.json manifest recording the full document page count. Page count changes fail with both counts. --update-snapshots writes the selected pages and removes stale page PNGs, including pages removed by pagination changes. The legacy string form retains first-page behavior and tolerancePixels.

For the object form, both thresholds must pass: differing pixels must be within tolerancePixels when set, and the percentage must be within thresholdPercent (default 0). pixelThreshold controls pixelmatch colour sensitivity (default 0.1), independently of the allowed percentage. A page-three-only change produces artifacts only for page three:

pulp-engine-test-results/<fixture>/<case>/page-003.expected.png
pulp-engine-test-results/<fixture>/<case>/page-003.actual.png
pulp-engine-test-results/<fixture>/<case>/page-003.diff.png

A missing baseline produces actual/diff artifacts where possible. Use the same OS/container image, fonts, Chromium and pdfjs versions when generating and checking baselines. Anti-aliasing can differ across environments; percentage tolerance absorbs small rendering noise. Local preview HTML can differ from production HTML (for example asset inlining); keep their baseline sets separate.

Structural snapshots and format coverage

DOCX snapshots read document, header and footer XML from the ZIP. They retain paragraph styles and text (P[Heading1] Invoice), table dimensions and rows, image dimensions and page breaks. They omit volatile relationship IDs. XLSX snapshots retain sheet names, frozen panes, cell coordinates, values, types, number formats and formulas. Both use readable line diffs.

Format Checks
PDF Multi-page pixels, page count, maximum bytes, optional text
HTML Normalized snapshot and text
DOCX Structural outline snapshot
XLSX Structural cell-grid snapshot
CSV Per-table data record counts
PPTX No dedicated snapshot/assertion support in this wave

Reports and CI

--reporter html writes a standalone index.html under the artifacts directory. It opens offline and links expected, actual and diff images side by side. Console output names artifact paths and all failed checks. JUnit includes [[ATTACHMENT|path]] markers in system-out.

Upload the whole artifacts directory, preserving its relative paths, and the JUnit XML even when tests fail. .github/workflows/template-ci.sample.yml demonstrates actions/upload-artifact with if: always() and promotion after a successful test job. Never update baselines automatically as part of a regression check; review changes before committing them.

Saved dataset checks in the editor

Datasets may store the content subset as assertions: page count, literal HTML contains/notContains, maximum PDF bytes, missing bindings and table row counts. POST /templates/:key/tests/run runs at most the configured dataset cap with concurrency two, skips negative/schema-stale cases, and returns check results plus testRunId. It never returns rendered bytes, input data or tenant IDs. Every run is audited. Its PDF helper shares tenant asset, resource trust and dispatch policy with the PDF handlers.

The editor’s Dataset details exposes these checks. When /capabilities.templateTests is true, the publish gate runs them against the unsaved template and blocks failed cases. Otherwise it uses the dataset validation preflight. The editor rechecks dataset revisions before publication. Server promotion does not enforce this verdict; a test-run ID is a trace breadcrumb, not authorization to promote.

PDF text and raster checks use compatible PDF.js 6 and pdf-to-png-converter 4 versions. These optional tools require Node 22.13 or later. Windows reserved dataset names such as con use __con.json on disk; the metadata and API name remain con.