Testing
Acceptance levels in detail
- Library-API pin test — the real
Pipeline.from_config,Export,auto_importandPipeline.runon a one-page CPU fixture, inside the built wrapper image: the canary for an htrflow bump that breaks the driver. No model is loaded, so it runs offline in seconds, wherever a wrapper image was just built (CI's second-architecture job, the wrapper scan job, andpublish-dockerbefore the push). Locally:make test-driver-realordagger call test-driver. - Unit tests, everything mocked. Wrapper: manifest walking (IIIF
Presentation 2 and 3, sized requests, the 400 fallback to the largest size
the image's
info.jsonoffers within the cap), fetch acceptance, resume, the streaming loop, the verification gate, exit-code mapping, log shipping, warm-up classification. Converter: parse, append-only, render against golden fixtures, the 10 000-volume split, the chart-agreement test. Read API:projection.py's pure functions and the routes. Frontend: schemas, derivation, the ALTO parser and components on jsdom, plus property-based tests (fast-check,*.property.test.ts) for the URL guards, the run-log parser and the failure sentences: each run draws a fresh seed and prints it with a counterexample, andFC_SEED=<seed>replays that run. - Container smoke — the batch image against a real two-page manifest
with a RustFS target; assert PAGE and ALTO files and
manifest.jsonland. - Cluster acceptance —
a. one small volume end to end;
b. about ten volumes: never more than quota running, the rest suspended,
all eventually Complete, one
manifest.jsoneach; c. kill a running pod mid-volume: the retry resumes and converges, with no duplicate or corrupt outputs; d. a campaign rendered and applied: declared in git,htrflow-campaigns renderand apply, watched on the campaign browser with its live log; e. the fetch-vs-HTR numbers from the publishedmanifest.jsonfiles, the evidence the cache layer proposal waits on.
Level 1 runs in seconds and gates every change; level 2 is the local compose stack; level 3 needs a real GPU cluster.
How to run each level
Level 1 — unit tests:
make test # uv run --all-packages pytest -q
cd frontend && bun run test # vitest
# or, reproducibly, the way CI runs it:
dagger call test # add --ca-bundle <file> behind a TLS-inspecting proxy
cd .dagger && go vet ./publishcheck/ && go test ./publishcheck/ # the dagger module's Go test
The Go test reads the order of publish-docker's gates from the dagger
module's syntax tree, so a gate commented out or moved after the push fails
it. It needs no engine; ci.yml runs it in a job of its own.
The generated files CI checks are current
Three files in this repository are printed by a script and committed, and in
each case a test in the normal suite fails when the committed copy is not
what the script prints — so dagger call test (and therefore make ci)
catches a stale one without a job of its own.
| File | Regenerate with | The test that asserts it |
|---|---|---|
docs/reference/configuration.md |
make config-reference |
packages/converter/tests/test_chart_agreement.py |
frontend/src/lib/fixtures/api-contract.json |
make api-contract |
packages/web/tests/test_contract.py |
frontend/src/lib/fixtures/wrapper-contract.json |
make wrapper-contract |
packages/wrapper/tests/test_contract.py |
The two contract fixtures tie the Python side to the page with no copy in
between. scripts/api_contract.py asks the read API's routes over a fake
cluster (live, reaped and unknown campaigns, a failed volume, bucket
progress); api-contract.test.ts parses every row with the page's schemas
and fails when the page drops a field it does not mean to drop. scripts/wrapper_contract.py
calls the wrapper's own functions for a manifest.json and the termination
messages; the run viewer's schema and the failure-sentence tests read them.
Change a projection and the pytest fails; run make api-contract and the
vitest tells you whether the schemas can still read what you changed.
make test runs the three Python packages (wrapper, converter, web); the
root pyproject.toml's testpaths names exactly those three. dagger call
test runs the same suite inside a uv container after uv sync --frozen
--all-packages, which pins the dependency resolution but says nothing about
the production images — those are built separately by dagger call
build-wrapper and build-web (Releasing). make typecheck
(ty) is a separate gate; run it before pushing (CI).
Level 2 — container smoke, through the local compose stack:
make compose-up # background: S3 (RustFS) + fixtures + wrapper + web front
make compose-smoke # foreground: runs the wrapper to completion, then
# checks /uv.html and reads the result, logged in
make compose-down
make compose-up runs the published release the compose file pins by
digest (the same digests as the chart and the demo pipeline); it never
builds. make compose-smoke builds both images from the checkout with the
same recipes as the images that ship (build-wrapper, build-web, tagged
<registry>/…:<IMAGE_TAG> like every local build), runs the wrapper to
completion on them, then brings the web front and the results proxy up,
fetches /uv.html, and reads the volume's iiif.json through /results:
refused without a login, served after logging in as the stack's read-only
user — the default local check. The compose file takes the two
images from HTR_WRAPPER_IMAGE and HTR_WEB_IMAGE, which is how the smoke
runs what it built. make compose-smoke-run WRAPPER_IMAGE=<ref>
WEB_IMAGE=<ref> runs the same smoke on any two images. The stack is torn
down, volumes included, however the run ends. dagger call compose-test
drives the web service alone, on the pinned web image.
HTR_COMPOSE_S3_PORT and HTR_COMPOSE_WEB_PORT move the host ports (make
arguments, or .env); the stack itself is described in
Quickstart.
The web service runs site-only in both (HTRFLOW_WEB_SITE_ONLY=1): a compose
stack has no API server, so /api/v1/… answers 503 by design and the site is
what this level checks.
Chart: make helm-template lints and renders both charts
(charts/htrflow-batch, charts/htrflow-devstack) on their defaults and on
each chart's ci/full-values.yaml (every optional feature on, no cluster
lookups), checks that the devstack chart refuses RustFS without chosen
credentials, and runs kubeconform -strict when it is installed.
Campaigns repo shape: htrflow-campaigns validate examples/campaigns
must pass — packages/converter/tests/test_cli.py runs it, so the checked-in
example repo cannot drift from the converter it demonstrates.
Level 3 — cluster acceptance: no single make target. It needs a
Kubernetes cluster with GPU nodes and the chart
deployed, exercised with kubectl or k9s
and the campaign browser as described in
Run a campaign and
Dev cluster. make e2e DIR=<campaigns-repo> automates the
happy path: validate, render and apply, then block until every campaign Job
reaches a terminal condition. A Failed Job, or no campaign Job at all, fails
the target. Kill-and-resume (c) is by hand: once a few
ALTO files exist under a volume's prefix, force-delete the running pod and
watch the retry pod log resume: <n> done, <m> to process and converge to
Complete.