Testing and CI#
Audience: contributors
Time: 10 minutes
What you’ll get: how to run the suite locally, what it covers, and what CI checks
Running the tests#
From the cellucid-r/ directory:
install.packages(c("testthat", "jsonlite", "httpuv", "later", "curl", "Matrix", "SeuratObject"))
testthat::test_local()
The full check, as CRAN runs it (the package compiles a few C files and links zlib, so a C toolchain is needed):
R CMD build .
R CMD check --as-cran cellucid_<version>.tar.gz
Matrix, SeuratObject and the Bioconductor packages (SingleCellExperiment,
SummarizedExperiment, S4Vectors, DelayedArray) are Suggests
dependencies; the tests that need one skip without it, so install them to run
those tests.
The anndata round trip#
tests/testthat/test-anndata-interop.R writes a small dataset as a .zarr
folder and as a .zarr.zip, reads each back with Python’s anndata
(tests/testthat/python/read_with_anndata.py, every warning an error), and
compares every value with what R put in: cell and gene names, every obs
column, X, every obsm array, the graph, and uns. It uses the Python named
by the CELLUCID_ANNDATA_PYTHON environment variable, else python3 on the
PATH, provided it can import anndata, zarr; otherwise, and on CRAN, it is
skipped.
CELLUCID_ANNDATA_PYTHON=/path/to/venv/bin/python \
Rscript -e 'testthat::test_local(filter = "anndata")'
It also writes integer counts above 2^24 and reads them back exactly, as float64.
The cross-writer check#
tests/testthat/test-cross-writer.R writes several inputs (every kind of
column, stored zeros, counts above 2^24, dense and sparse expression, only
embeddings) with cellucid_prepare(), describes the same R objects in JSON,
and has tests/testthat/python/cross_writer.py write them with Python’s
cellucid.prepare() and compare the two stores document for document and
value for value (floating values byte for byte, so a NaN must be the same NaN). It needs a Python that imports cellucid, anndata and
zarr, named by CELLUCID_PYTHON (else python3):
CELLUCID_PYTHON=/path/to/venv/bin/python \
Rscript -e 'testthat::test_local(filter = "cross-writer")'
Run both whenever you change what the store holds, in either writer.
test-served-cross-language.R does the same for what cellucid_view() and
Python’s show_anndata() serve, with the same CELLUCID_PYTHON.
What the suite covers#
File |
What it checks |
|---|---|
|
The written store, read back with an independent base-R reader ( |
|
Python’s anndata reads both outputs without a warning, value for value |
|
The same input written by |
|
The outlier-quantile kernel against the web app’s numpy conformance fixture ( |
|
|
|
The make-unique rule and its note, the vector-field key rule and the default modality against the conformance fixture shared, byte for byte, with |
|
Required files in a checkout and in the built tarball, no retired wording, README links, workflow branches, |
|
|
|
A matrix held outside R’s memory (BPCells by cells and by genes, DelayedArray over a sparse and over a dense backend) written and served byte for byte as its |
|
The plan where an object departs from the simple case: an assay holding its cells in another order or only some of them, a factor with an |
|
Session format 4 against the shared golden history ( |
|
The chunk layout the web app’s writer makes ( |
|
|
|
The methods text and the pseudocode for what the golden branches do not reach: a squashed step of each cause (recording paused, steps merged, the history cleared, a state shared), the sentence that a squashed step holds only its result, everyone who acted while paused, and the Before/After group headings; the same strings as cellucid-python’s |
|
The history live, against a viewer played by a WebSocket client in this process: the actions inside |
|
The action catalogue ( |
|
The store |
|
Python’s mudata reads R’s Cellucid-ready MuData, value for value ( |
|
The viewer channel’s declarations against the shared fixture |
|
The server end to end on free loopback ports: routes and documents, the web build, every store key against the prepared store, exact ranges (206, 416, suffix, |
|
The server and channel off the happy path: |
|
SHA-256 against FIPS 180-4 vectors; the inventory schema; the build id; a cache checked file by file; a build downloaded from a local source, kept, replaced, refused when a file is wrong, and used offline |
|
The packaged registry |
|
The generated citation regions match |
|
An R session whose native encoding is not UTF-8, as Windows R before 4.2 is ( |
|
Every size constant in |
|
Every base R function the package, its tests, |
|
The priority order ( |
|
|
|
|
|
The same round trip in a Jupyter notebook against stand-in comms: nothing waits for the viewer, and a session asked in one cell is collected in a later one |
|
The viewer in a notebook ( |
|
A notebook cell run again stops the viewer it showed before (its matrices and chunk cache released), unless another cell shows it; without a cell id, showing a dataset again stops its earlier viewer and says so; a served store keeps a 64th of the machine’s memory by default, as cellucid-python’s |
|
The store |
|
A served expression matrix laid out gene-major in the background on every core: a chunk asked for before the layout ends is made from its progress (values already laid out copied, only the rest passed over), counted, never timed. |
|
The gene sample as R reads it: a session’s |
|
|
|
The same object served by R and by Python holds the same bytes in every array both serve, metadata and every chunk, with float64 values, stored zeros and duplicate coordinates stored alike ( |
|
No R surface repeats a privacy claim the code contradicts: community annotation signs in on www.cellucid.com, a server on 127.0.0.1 is readable by every user of a shared machine, a session applied to an object records no |
|
A count and its noun agree in every message: |
CI#
.github/workflows/R-CMD-check.yaml runs R CMD check --as-cran for every
push and pull request to main: on Ubuntu with R 4.1, 4.2, 4.3, oldrel-1,
release and devel, and on macOS and Windows with R 4.1 and release. Every job
installs all suggested packages (SeuratObject, SingleCellExperiment and the
other Bioconductor ones) except three: one Ubuntu job with none of them, one
with SeuratObject 4.1.4, and the macOS and Windows R 4.1 jobs, which check with
the hard dependencies plus knitr, rmarkdown and testthat (the build and check
tools, installed explicitly rather than taken from the runner image). Windows R
4.1 checks both its sub-architectures, 32-bit i386 (where R_xlen_t is an
int) and x64, in a CP1252 session. CRAN no longer serves R 4.1 Windows binaries, so
the Bioconductor stack would build RCurl from source without curl headers, and
Matrix 1.6-5 has no R 4.1 arm64 binary and does not link from source there; the
suggested-package paths are covered at R 4.1 on Ubuntu. The R 4.1 and 4.2
Ubuntu jobs pin Matrix 1.6-5, the last release for R older than 4.4, which
SeuratObject 5 needs. The Ubuntu release job installs anndata, mudata and cellucid-python at the
commit CELLUCID_PYTHON_COMMIT names (in the workflow’s job env), so the
cross-writer, served cross-language and interoperability tests run there;
elsewhere they skip, naming what is missing. The two packages write and serve
the same bytes only at matching commits, so the job never installs
cellucid-python’s main or a PyPI release: when a change touches what both
write or serve, push the cellucid-python commit first and set
CELLUCID_PYTHON_COMMIT to it in the same cellucid-r commit (a commit not on
GitHub fails the install step by name).
Bioconductor packages come from bioconductor.org (R_BIOC_MIRROR): pak’s
default mirror, bioconductor.posit.co, keeps only Bioconductor 3.19 and later,
and R 4.1, 4.2 and 4.3 need 3.14, 3.16 and 3.18.
What CI sees that a local run does not#
The run of 2026-10-31 (run 41212101986) failed on 8 of
12 jobs while testthat and R CMD check --as-cran passed on a current
macOS. Each failure came from a difference between the machines, and each
is now covered where a local run can reach it:
Old Bioconductor. Up to Bioconductor 3.18 (R 4.1 to 4.3), DelayedArray’s coercion of a block to a
dgCMatrixwent through a dense block andwhich(block != 0), which dropsNAandNaN: a missing value in an HDF5Array or other DelayedArray assay was written as a zero, and the test that expects it refused failed on those jobs only. A block is now read withDelayedArray::read_block()(sparse when its backend is) and converted without that step. To run the suite on those releases, make an Intel conda environment (Apple silicon runs it under Rosetta), for exampleCONDA_SUBDIR=osx-64 mamba create -p ./r41 --platform osx-64 -c conda-forge -c bioconda "r-base=4.1" "bioconductor-delayedarray=0.20.0" bioconductor-singlecellexperiment r-testthat r-jsonlite r-httpuv r-later r-curl r-matrix(r-base=4.3withbioconductor-delayedarray=0.28.0for Bioconductor 3.18), install testthat 3.2.1 or later from CRAN into it, and install the package withSDKROOTset toxcrun --show-sdk-pathandMAKErunning/usr/bin/makeas arm64 (arch -arm64), since the Intelmakelauncher cannot load Xcode’s arm64-onlylibxcrun.Windows’ C library. Its
printfbreaks a decimal tie as it likes: the subsample size of a fraction came from its shortest decimal, found withsnprintf(), and 2^-25 was written…313instead of the even…312, so 2^-25 of 50,331,648 cells came out 2. The shortest decimal is now found in exact integer arithmetic (src/subsample.c), checked against Python’srepr()on about 50,000 doubles (every power of two in (0, 1] and its neighbours, subnormals, short decimals, random bit patterns).Windows’ files. An open file keeps its name there, so the layouts’ scratch files (
FILE_FLAG_DELETE_ON_CLOSE) are listed in the session folder until the viewer stops; the test asserted the Linux and macOS behaviour everywhere.R 4.1 on Windows. Rtools 4.0’s toolchains declare no Windows API newer than XP by default, so
GetTickCount64()did not link in the 32-bit build; the layouts’ clock is now the performance counter. Every C file can be compiled for 32- and 64-bit Windows at that API level from any machine withzig cc -target x86-windows-gnu -D_WIN32_WINNT=0x0502(andx86_64-windows-gnu), with the R and zlib headers on the include path.httpuv. After
onHeadersanswers a WebSocket handshake (403, 421), httpuv writes its own 101 and opens the socket all the same; the server now closes such a socket as it opens (.on_server_ws_open()). A test that closed its client before that open ran made httpuv skip the open but still deliver the close, whose handler warns “object ‘…’ not found” in whichever test turns the event loop next (seen on Windows); the tests now wait for the server’s close.httpuv’s writes. httpuv sends a frame as a header, a payload and an empty last buffer, and libuv leaves a write whose last buffer is empty unfinished until the next turn of its loop; a frame sent right after another waits for that turn, and stopping the server first drops it.
cellucid_stop()sent the freeze and the close together and stopped at once: the page got the freeze, then the connection’s end with no close (macOS release; every time under Rosetta, where it reproduces locally). It now waits for the page to answer the close (.viewer_close_seconds, one second, bounds a page that never answers) before stopping.cellucid-python’s
main. The job installed cellucid-python frommain, which did not yet hold the served store’sSERVED_ENTRY_CHUNK_ELEMENTSthat cellucid-r’s push of 2026-10-31 was written against; it now installs the pinned commit (above).
Two warnings in those runs are not the package’s: loading SummarizedExperiment warns “replacing previous import ‘S4Arrays::makeNindexFromArrayViewport’ by ‘DelayedArray::makeNindexFromArrayViewport’” with the Bioconductor binaries those jobs install (R 4.3 with S4Arrays 1.2.1 and DelayedArray 0.28.0, and on oldrel-1, release and devel), in the first test that loads it; DelayedArray re-exports that function from S4Arrays, and S4Arrays 1.2.0 with DelayedArray 0.28.0 (R 4.3), or 1.12.1 with 0.38.2 (R 4.5), load without the warning. The server’s log line “a request for /assets/app.js failed: Web asset ‘assets/app.js’ byte length is 9” is the test that tampers with the web build on purpose.
release.yaml builds the source tarball and runs R CMD check --as-cran on it
for a published release, then attaches it to the release and submits it to
CRAN; see Release Process and
cellucid-r/publishing.md.