Skip to content

Test suite, CI, and a pipeline that runs off our cluster - #2

Merged
Paururo merged 31 commits into
mainfrom
feat/tests-ci
Aug 6, 2026
Merged

Paururo merged 31 commits into
mainfrom
feat/tests-ci

Conversation

@Paururo

@Paururo Paururo commented Aug 6, 2026 •

Copy link
Copy Markdown
Member

Gives BAMpiro a test suite and CI, makes it run correctly on a machine that is not our cluster, and takes apart the three parts of the codebase that had grown untestable.

Warning

-profile standard no longer submits to SLURM. It is now the portable default and runs on the current host. Use -profile slurm for a generic cluster, or -profile garnatxa for ours, which carries the bind paths, QoS, module load and Kraken database and replaces the -c cluster.config file we used to write by hand.

Process names are now qualified (CALL_VARIANTS:CALL_FREEBAYES), so task hashes change and the first run after this will not -resume from an older work directory.

Tests and CI

Around 1,200 tests in three legs, all runnable with tests/run_tests.sh. None of them needs a container, a reference genome, real reads or a network connection.

Leg Covers
tests/unit/ Everything under bin/: the consensus decision tree, the QC verdict engine, every parser, the k-mer liftover, the read filter, and the awk and filter expressions the process scripts call
tests/js/ The report's hand-written ES5 statistics, checked against SciPy and statsmodels reference values rather than a snapshot of our own output. Current agreement is around 1e-12
tests/pipeline/ Samplesheet validation, parameter handling, and a full -stub-run of the DAG on both test profiles

Every process gained a stub: block, so -stub-run walks the whole workflow in seconds. -profile test runs the bundled 170 kB fixture cohort (a 5 kb reference, three samples covering paired-end, single-end and a sample split over two runs); -profile test_full turns on every optional branch so a single run instantiates every process, and a test asserts exactly that. The cohort derives from one seed and CI regenerates it and fails on any diff.

CI gates every pull request with lint, unit tests on two Python versions, the front-end tests, and a stub run against both the minimum supported Nextflow and the current release.

Fixed

The suite paid for itself before it was finished.

  • The QC report could not be produced on the default settings. Its three optional inputs all fall back to a placeholder and all three features are off by default, so every run handed QC_REPORT the same NO_FILE path three times and Nextflow refused to stage them. 66 tasks would complete and then the run died, right before the headline output.
  • main.nf did not compile under the strict Nextflow parser, the default since 25.10, and only ran through the deprecated v1 fallback. Now verified on 24.04.2 and 26.04.6.
  • A GFF attribute was matched as a substring, so locus_tag= also matched inside old_locus_tag= and gene= inside pseudogene=, both routine in RefSeq and Prokka output. Genes were labelled with an obsolete tag and the curated H37Rv map could be silently overridden.
  • Four parsers wrapped a whole read loop in one try/except, so one malformed line silently discarded every line after it; a partial genome size then propagated into depth, breadth, evenness and every density bin.
  • Genuinely-zero metrics were written as NA, making a failed sample indistinguishable from an unmeasured one.
  • Genotype classification assumed diploid spellings, so a haploid no-call counted as heterozygous. That matters for -profile generic, which is haploid.
  • safe_tabix had drifted between its two copies: the one in annotation.nf still used a check that cannot tell a truncated VCF from an empty one, and wrote a fake index without saying so.
  • A missing FORMAT/DP reached the consensus as ADP=., which reads as zero depth and turns a called SNP into a gap.
  • Plus mask_profile reporting above 100% masked, get_ro_ao returning a half-initialized result, IntervalMasker silently dropping every interval on a contig named chrom*, two stream parsers crashing on a blank line, a BED coordinate allowed to be negative, and a known depth thrown away.

Portable by default

The base config used to be one machine: a SLURM executor with --qos=short, module load singularity, /scr and /storage bind paths, a Kraken database under /storage, and a leftover samplesheet name. All of it moved into conf/garnatxa.config.

  • --tsv is required and says what to pass. --kraken2_db has no default, and without one the contamination screen is skipped.
  • The container is pinned by digest instead of the mutable 1.0.1 tag, so two runs a month apart cannot silently use different tool versions.
  • An unrecognised --parameter stops the run with a suggestion (--treads -> "did you mean --threads?") instead of being accepted and ignored. The declared set is read from nextflow.config, so the two cannot drift.
  • --help lists every parameter with its default.
  • --max_cpus / --max_memory / --max_time cap every request to what the machine can give. Without them Kraken2 asks for 80 GB and the local executor simply refuses to schedule it.
  • The startup banner reports the resolved profile, executor, container and Kraken state, because where a run lands is the easiest thing to get wrong.

Refactored

Behaviour-preserving throughout, and each step verified rather than assumed.

Before After
main.nf 556 lines 277, one sub-workflow per stage
bin/qc_report.py 1697 lines, 53 functions 319, over a bin/qcreport/ package
modules/variants.nf 485 lines 344

The science came out of the process scripts. Around 500 lines of bash and awk lived inside modules/*.nf, where no test could reach it: -stub-run replaces the script block and nothing can import it. The genotype re-validation, the backbone pileup parser, the hom/het bcftools expressions and safe_tabix now live in bin/, with 62 tests over them including a run against a real bcftools. Two of those pieces were duplicated across processes, and one copy had already lost the comment explaining why it exists.

Each refactor was checked, not asserted: the report produces byte-identical output on the demo cohort with the clock frozen; the workflow was run side by side with the previous main.nf and the execution traces diffed to identical task counts and identical published outputs; the extracted awk was compared character by character with the version it replaced before being swapped in.

Also

Community files (CONTRIBUTING, a Code of Conduct, a security policy, issue forms, a pull-request template), RELEASING.md recording the order a release has to happen in, and .zenodo.json ready for a DOI. Minting it needs the repository public, so that step is still blocked, along with the docs site and the two README badges pointing at it.

Not a release. manifest.version reads 1.1.0, which is the number this work is heading for, but nothing is tagged and no release is published; the changelog section stays [Unreleased] until one is. The container also stays at the 1.0.1 image and its digest: no bundled tool changed, and the scripts added under bin/ are staged by Nextflow from the pipeline directory rather than baked into the image.

Paururo added 30 commits August 6, 2026 17:13
Nextflow 25.10 and later default to a stricter parser that refuses an assignment
used as an expression and drops while loops entirely, and both appear in the
samplesheet parsing. Splitting one and replacing the other with list padding is
behaviour preserving. main.nf still needs its script-level code moved inside the
workflow before that parser accepts the file outright.
QC_REPORT takes three inputs that fall back to a placeholder when their feature
is off, and all three are off by default, so every default run handed it the same
"NO_FILE" path three times and Nextflow refused to stage them. The placeholders
are now distinct real files under assets/, referenced through projectDir so a
stray NO_FILE in the launch directory is never staged in their place.
A stub block lets `-stub-run` execute the whole DAG in seconds with no container
and no reference data, which is what makes a pipeline-level check cheap enough to
run on every change. Each stub creates exactly the outputs its process declares.
tests/data is a 170 kB cohort (5 kb reference, three genes, three samples) built
from a fixed seed, covering paired-end, single-end and a sample split over two
runs. `-profile test` runs it with the default feature set; `-profile test_full`
turns on every optional branch so one run instantiates every process.
990 tests over the consensus decision tree, the QC verdict engine, every parser,
the k-mer liftover and the read filter. The ruff configuration is deliberately
narrow, selecting the rules that catch real defects rather than imposing a
restyle, and it excludes the vendored KrakenTools script.
The report computes Spearman, Kruskal-Wallis, chi-square and Benjamini-Hochberg
in hand-written ES5, because nothing else fits inside a self-contained HTML file.
These 77 tests assert that arithmetic against SciPy and statsmodels reference
values, and check the asset bundle parses and stays ES5. The harness reads the
module list out of qc_report.py, so the two cannot drift.
The validator gets one deliberately broken samplesheet per failure mode, plus a
check that it still reports every problem in a single pass. The stub-run tests
assert the DAG completes on both profiles, that test_full reaches every declared
process, and that a multi-run sample maps twice but merges once.
Lint, unit tests on two Python versions, the front-end tests, a stub run against
both the minimum and the latest Nextflow, and a strict docs build. A fixtures job
regenerates the test cohort and fails on any diff, so the committed data stays
something anyone can rebuild. tests/run_tests.sh runs the same checks locally.
Two more samplesheet cases and blank lines in two fixtures. That takes
bin/qc_report.py to 99% statement coverage outside main().
docs.yml already runs mkdocs build --strict with the system libraries the social
plugin needs, so it just gains a pull_request trigger; only the build job runs
there, since deploy stays gated on the Pages variable. Drops the stale
indel-mask branch trigger now that the branch is merged.
The guard tested `not line`, but iterating a file yields "\n" for a blank line
and never "", so it never fired and the row unpacking raised ValueError instead.
Flips the test that pinned the crash.
CONTRIBUTING covers setting up, running the suite locally, what CI gates on, the
ruff policy and the commit convention this repository actually uses, plus the two
known debt items a newcomer would otherwise trip over. Contributor Covenant 2.1,
a security policy, issue forms and a pull-request template alongside it.
The base config hardcoded one machine: a SLURM executor with --qos=short, `module
load singularity`, /scr and /storage bind paths, a Kraken database under
/storage, and a leftover samplesheet name. All of that now lives in a `garnatxa`
profile, so `-profile garnatxa` replaces the -c file a user needed before and a
clone runs correctly anywhere without being edited.

Adds a generic `slurm` profile, makes `standard` run on the current host, pins
the container by digest instead of the mutable 1.0.1 tag, requires --tsv with a
message that says what to pass, and prints the resolved profile, executor and
container in the startup banner.
Nextflow 25.10 and later default to a parser that rejects statements at the top
level of a script and requires a dynamic process directive to be a closure, so
main.nf only ran through the deprecated v1 fallback. The samplesheet parsing is
now a function, every statement lives inside the workflow, and each dynamic
publishDir is a closure.

Verified on 24.04.2 (the declared minimum) and 26.04.6; the test suite and CI no
longer set NXF_SYNTAX_PARSER.
Nextflow puts every --flag into params whether the pipeline declares it or not, so
with roughly ninety of them a typo like --treads was accepted, ignored, and the run
finished on the default. The declared set is read straight out of nextflow.config,
so the two cannot drift, and an unrecognised name now fails with a suggestion.
--help lists every parameter with its default, grouped by the config's own sections.

Also drops process.publishDirMode, which is not a Nextflow directive and only
produced a warning; the publish mode already reaches each process via
params.publish_mode. A new test flags any params.x referenced but never declared.
Each process asks for what its tool needs and Kraken2 asks for 80 GB, so on a
laptop the local executor simply refuses to schedule it: "Process requirement
exceeds available memory". --max_cpus, --max_memory and --max_time now feed
Nextflow's resourceLimits, so one flag makes the whole pipeline fit instead of
editing nine modules. Left unset, every process keeps the size it asked for.

The assignment sits after the profiles on purpose: it reads params.max_*, and a
profile that sets them is only merged when its own block is parsed.
The container is pinned by digest, so it has to be published before the tag that
references it, and the version lives in three files that have to agree. RELEASING
records that order. .zenodo.json is the archive metadata, ready for the DOI; the
minting itself is blocked until the repository is public.
main.nf now satisfies the strict parser, so NXF_SYNTAX_PARSER is no longer needed
and the note becomes a description of what that parser actually forbids. Drops the
"-profile standard on a cluster" hint from the bug template, which stopped being
the common failure once standard became the portable default.
Flags the profile change as breaking: -profile standard used to submit to SLURM
and now runs on the current host.
A GFF attribute was matched as a substring, so `locus_tag=` also matched inside
`old_locus_tag=` and `gene=` inside `pseudogene=`, both routine in RefSeq and
Prokka output: genes were labelled with an obsolete tag, and build_gene_map could
override a curated H37Rv locus. mask_profile summed raw interval lengths for its
headline total, so an unmerged BED could report above 100% masked.

Four parsers in stats_to_legacy wrapped a whole read loop in one try/except, so a
single bad line silently discarded every line after it; a partial genome size then
propagated into depth, breadth, evenness and every density bin. Genuinely-zero
metrics were erased to NA, which made a failed sample look unmeasured. Genotype
classification assumed diploid spellings, so a haploid no-call counted as
heterozygous. parse_dr dropped a float-formatted depth.

The tests that pinned each of these now assert the corrected behaviour.
Every command that meant "on a cluster" said -profile standard, which now runs on
the current host, and several pages carried a warning about standard failing off
a cluster that no longer applies. The HPC section of the configuring tutorial is
rewritten around -profile garnatxa instead of a hand-written -c file, keeping the
explanation of why binds are needed. Also corrects the container being described
as pinned by tag, --tsv having a default, and --kraken2_db pointing at storage
nobody else has, and documents --help and the resource ceilings.
Around 500 lines of bash and awk lived inside modules/*.nf, and none of it was
reachable by any test: a stub run replaces the script block and nothing else can
import it. Three pieces move to bin/, where the suite can exercise them.

format_snps_for_backbone.awk is the genotype re-validation, verified byte-identical
to the version it replaces and previously copied into both FreeBayes processes,
where the copy had already lost the comment explaining why it exists.
vcf_filter_rules.py builds the hom and het bcftools expressions, which are the
definition of a call and were also written out twice. safe_tabix had drifted: the
copy in annotation.nf still used the older zgrep check that cannot tell a truncated
VCF from an empty one, and it now gets the hardened version for free.

variants.nf drops from 485 lines to 393. 35 new tests cover the extracted code,
including a run against a real bcftools to confirm the expressions select what
they claim to.
FORMAT/DP can be ".", and the awk assigned it without coercing to a number, so the
three numeric fallbacks were skipped by a string comparison and ADP=. was written
out. The consensus reads that as zero depth and turns a called SNP into a gap.
The upstream filter requires a real depth, so this was probably unreachable, but
every fallback in that block exists precisely to guarantee a number.
CALL_BACKBONE carried two copies of count_bases, one per branch, differing only in
whether a BAQ-adjusted depth was available to compare against. That function reads
an mpileup base string, where a read start is a caret plus a mapping-quality
character that must not be counted and an indel is a sign, a length and that many
bases belonging to the previous position. Miscount any of it and the allelic depth
the consensus relies on is quietly wrong, and nothing was checking.

One program now, parameterised by a BAQ flag, with 27 tests over the pileup
grammar and the coverage call. variants.nf drops from 393 lines to 342.
1697 lines and 53 functions in one file, covering file parsing, the QC verdict
engine, the panel builders, the HTML assembly and the CLI. It is now a 319-line
CLI over bin/qcreport/, split along the seams that were already there: parsers,
metrics, panels and render.

The move is mechanical. 75 of the 76 top-level definitions are byte-identical in
exactly one module; the exception is _ASSET_DIR, which needed one more dirname()
to keep resolving to bin/report_assets from a directory deeper. With the clock
frozen the old and new versions produce byte-identical qc_flags.tsv and HTML on
the demo cohort, so behaviour is unchanged rather than assumed unchanged.

Two tests pin what this refactor risks: that the module imports cleanly, and that
the CLI still runs when invoked by absolute path from an unrelated directory,
which is how modules/report.nf calls it.
main.nf was 556 lines with about 390 of them a single block of channel plumbing.
Each numbered stage is now its own sub-workflow with an explicit take/emit, and
the body of main.nf is config, channel construction and eleven one-line calls.

Every conditional moved inside the sub-workflow it belongs to, so a stage that is
switched off returns its empty-channel placeholder instead of main.nf branching
around it. Emit names had to diverge from the local names they carry: `emit: x =
y` is a real assignment, and a local `def x` in scope shadows the binding property
Nextflow then looks for.

Equivalence was checked by running the previous main.nf side by side and diffing
execution traces: identical per-process and per-task counts on both profiles, and
identical published output trees. Note that process names are now qualified, so
the first run after this will not resume from an older work directory.
Six defects the new tests turned up, plus two more the extraction exposed, and the
three monoliths that came apart: the bash inside the process scripts, qc_report.py
and the workflow body.
Six of these were assigned to a run that lost its work and never got picked back
up, so they stayed pinned as current behaviour while everything around them was
fixed.

get_ro_ao assigned the reference count before the alternate sum could raise, so a
malformed AD returned a half-initialized result. IntervalMasker recognised a
header row by its first field, which silently discarded every interval on a contig
named chrom-anything; it now looks at whether the coordinate columns are numbers.
FastaReader raised a bare IndexError on a header with no name. The SAM filter
indexed fields on a blank line. A BED coordinate could be negative and expand to
positions no reference has. build_snp_matrix threw away a depth that a
single-element AD had stated, emitting an empty cell for a known value.

build_min_unique_len keeps its behaviour but stops being silent about it: a
position genmap never scored is written as "placeable at any read length", which
is right at a contig end and wrong in the middle, so an interior hole now warns.
The smallest-K coverage rule stays as it is, because deriving coverage from every K
would change masking in a way no fixture can validate against real genmap output.
Points the reader at the warning instead of reading as an unfixed defect.
The directory tree in outputs.md still showed one flat bin/ and no sub-workflows.
It now covers subworkflows/, the qcreport package and the scripts the process
blocks call, with a note on why the science lives in bin/ at all: a script: block
is unreachable from any test, so anything with a decision in it has to be a file
the process calls.

CONTRIBUTING gains a table saying which layer a change belongs in, and the
emit-name collision that otherwise costs an afternoon. tests/README lists what the
extracted scripts decide and corrects the task counts and the module that builds
the report bundle.
The number this work is heading for, in the three places RELEASING says have to
agree. Nothing is tagged and no release is published: the changelog section stays
Unreleased until one is.

The container stays at the 1.0.1 image and its digest. No bundled tool changed,
and the scripts added under bin/ are staged by Nextflow from the pipeline
directory rather than baked into the image, so there is nothing to rebuild.
@Paururo
Paururo merged commit 349447b into main Aug 6, 2026
0 of 9 checks passed
@Paururo
Paururo deleted the feat/tests-ci branch August 7, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant