Version 3.10.1 · All versions

All pages

These are the docs as released with Solid Memo 3.10.1. Read the latest docs.

Testing

Every change to Solid Memo is checked by automated tests before it is published. Small tests check each part of the code on its own, every line of it; larger ones run the app against real Solid servers and click through it in a real browser. This page says what each kind covers, how to run them, and how to read a failure.

Unit tests with Vitest in every package, in node unless the package runs in the browser: ui, web, studio, solid and browser run in happy-dom, and the UI's tests use @testing-library/preact (vitest.shared.ts). Coverage is enforced at 100% (statements, branches, functions, lines) per package: each package's own tests cover its own code — npm test fails below that.

Commands

npm test          # every package's tests, with coverage thresholds (turbo)
npm run check     # Biome's lint, markdownlint and cspell, then npm test's tests, the round trip, typecheck, drift, Turtle formatting, the deck library, boundaries, the repository's own checks (below)
npm run check:ci  # npm run check without the deck library, as CI's build job runs it (its library job checks the library)
npm run browsers  # once: Chromium's headless shell, which npm run check and npm run build draw the docs' diagrams in (on Linux, if it lacks system libraries: sudo npx playwright install-deps chromium)
npm run lint      # Biome: the formatting and lint rules of the TypeScript, JSON and CSS (docs/style.md)
npm run format    # Biome: rewrite those files as it formats them
npm run coverage:summary   # the coverage the last tests measured, per package, as a table
npm run lint:markdown      # markdownlint over the project's Markdown, as npm run check runs it
npm run lint:spelling      # cspell, American English, over the Markdown and en.json, as npm run check runs it
npm run lint:reuse         # reuse lint: every file states its copyright and license, as CI's docs job checks (needs reuse; not in npm run check)
npm run test:unit -- packages/ui/src/ui/App.test.tsx   # some files, or all with none, without coverage (root vitest.config.ts)
npm run test:watch # watch mode, every package's tests
npm run test:unit -w @solid-memo/domain -- account.test.ts   # one package's (test:watch too)
npm run test:roundtrip -w @solid-memo/solid   # every library release through a draft and back (below)
npm run crosscheck # the pySHACL cross-check, as CI runs it (needs scripts/requirements-ci.txt)
npm run test:crosscheck   # the cross-check script's own tests, Python's unittest (needs scripts/requirements-ci.txt too)
npm run check:closes -- --base origin/main   # a commit since the branch left main closes an issue (add --body-file <file> for the pull request's body), as CI's commits job checks it (CONTRIBUTING.md)
npm run check:breaking -- --base origin/main   # the public API against where the branch left main, as CI's library job checks it (docs/releasing.md)
npm run test:pod  # the end-to-end tests, against the Solid servers they start in Docker
npm run servers -w @solid-memo/e2e-pod   # pull or build those servers' images ahead (`-- css-6` for one)
npm run pod       # a Community Solid Server at http://127.0.0.1:3999/, in memory, to poke at by hand
npm run pod:clean # take down the servers an interrupted run left
npm run journeys  # the user journeys: the built app in Chromium (a few also in Firefox, WebKit and on a phone), against a Community Solid Server 7 started in Docker
npm run journeys -- --project=chromium   # one browser's journeys (chromium, firefox, webkit or phone)
npm run journeys -- --ui  # the same in Playwright's UI mode: watch, pick, step through and time-travel a journey
npm run journeys -- -g smoke   # only the journeys whose title matches (or --repeat-each=10, or any other Playwright option)
npm run css -w @solid-memo/e2e-journeys   # keep such a server up; JOURNEY_SERVER_URL=<its url> npm run journeys reuses it
npm run journeys:report -w @solid-memo/e2e-journeys   # open the last run's report
npm run css:clean -w @solid-memo/e2e-journeys   # take down the servers an interrupted run left
npm run check:providers   # the Pod providers onboarding suggests answer with their issuers (needs the network)

npm run test:pod runs e2e/pod/ once against each Solid server servers.ts knows, which its global setup (globalSetup.ts) starts in Docker, each on a free port of 127.0.0.1 and told that URL, and takes down after (or as the run ends, on Ctrl-C); the test names say which server and version:

IdServerImageTier
css-7Community Solid Server 7.x, in memoryits own, solidproject/community-server, pinned by digestblocking
css-6Community Solid Server 6.x, in memorythe sameblocking
nss-6node-solid-server 6.xbuilt here from its lockfile (servers/nss/), in a root whose ACL lets anyone read and writeblocking
nss-5node-solid-server 5.xthe sameblocking
css-acpCommunity Solid Server 7.x with Access Control Policies in place of Web Access Control, in memorybuilt here on css-7's image (servers/css-acp/) with a configuration of its own, its root's ACR openblocking
css-8Community Solid Server 8, still in alpha, in memoryits own, pinned by digestadvisory
pivotPivot, the server of solidcommunity.net (the Community Solid Server 7 with Pivot's components), in memorybuilt here from its lockfile (servers/pivot/) with a configuration of its own, its root openadvisory
nextcloudSolid-Nextcloud: Nextcloud 32 with the Solid app, on SQLite, the pod apps/solid/~alice/storage/built here from a commit of the app (servers/nextcloud/), installed on every start, alice's pod opened by a hookadvisory
jssJavaScript Solid Server, files in the container, open to anyone (--public, which skips its access control)built here from its lockfile (servers/jss/), unmodified and pushed nowhere (it is AGPL)advisory

A blocking server's tests gate CI, and so deploys and Renovate's merges: every test passes or skips, for a reason the tests give. An advisory server's results are reported, not a gate: the Interop workflow (interop.yml) runs them on pushes to main that touch the app's Solid code, weekly, and by hand, and a job fails only on a test failing that e2e/pod/servers/<id>/expected-failures.json ({ "<file> > <test>": "<why>" }, the test named without its server) does not list, or when no test ran (compare.ts says which in the job's summary, and what passes though listed, to take off the list). CI still checks that every advisory server starts and meets the contract, so a change that breaks one is not merged unnoticed, and Renovate merges an advisory server's updates once that passes, as it does a blocking one's. Solid-Nextcloud's SQLite refuses writes made at once, so with it in a run the test files run one at a time (serialFiles in servers.ts); what it fails, and why, is its expected-failures.json. css-8 becomes blocking at 8.0.0, and css-6 then goes.

Each server is a compose file, e2e/pod/servers/<id>/compose.yml. It may ask only for E2E_PORT (the port on 127.0.0.1 the harness picked, which the server must be told) and E2E_SECRET (a password made up for the run), publishes on 127.0.0.1 only, and gets nothing of the host; npm test checks that of every one (servers.test.ts). Images built here are tagged localhost/solid-memo-e2e-<id>:local and pushed nowhere; the others are pulled once. Before the tests, every server must meet a contract (contract.ts): a document anyone writes in its pod root reads back, is listed there at the URL it was written to, and deletes. A server that is not open, or does not know the URL it is reached at, fails there, saying so, not in every test. What a server prints goes to e2e/pod/logs/<id>.log, which CI keeps when a job fails.

The tests' fetch sends every request with Connection: close (setup.ts). Node 24's fetch can write a request onto a pooled connection the server closed while a long synchronous stretch (a big update, on a slow runner) kept it from noticing, and then fails it without resending it; a browser resends it, so the app needs no retry. The Fetch standard forbids the header, but Node's fetch honors it, and npm test checks that it still does (connections.test.ts).

SOLID_SERVERS=css-7,nss-5 runs the suite against some only (all against every one; unset, the blocking ones; css-8 for the advisory); SOLID_SERVER_URL against a server of your own instead, which must let anyone read and write, as npm run pod's does (SOLID_SERVER_NAME names it in the tests; NODE_EXTRA_CA_CERTS trusts its certificate, if a CA of your own signed it). CI runs one job per blocking server, side by side, none stopping the others (migrations.md); Renovate keeps each server on its major (renovate.json5), and which servers each workflow runs is the table's in servers.ts (npm test checks). MEASURE_ANSWER_LOG=1 adds a measurement of reading a large answer log to history.integration.test.ts: two years of answers, seeded in about a minute and a half a server, its timings printed (shown with --silent=false; statistics.md). MEASURE_GRADES=1 adds one of grading to grading.integration.test.ts: a session's grades on a deck of 2,000 states, the requests, bytes and time of each printed (srs.md). CI runs neither.

In CI the images come from GitHub's cache, not Docker Hub, whose limit on pulls without an account the runners share: each job restores its server's images (e2e-images), and only when the cache has none under the job's key does it pull or build them, then, on main, save them (e2e/pod/images.ts). A pull request and the merge queue read main's images but save none: GitHub keeps what a run saves under its ref, so their copies would never be read again, and they would count toward the repository's 10 GB of caches, past which GitHub evicts the least recently used. A pull request that changes an image (a Renovate digest update, say) pulls it, until main has saved it. The key is what names the images (node e2e/pod/servers.ts key <id>): the compose file, which pins a pulled image by digest, or, for an image built here, a hash of its compose file and of every file of its build context that Docker is sent (what .dockerignore keeps out is left out, so expected-failures.json changes nothing) and whether each is executable. prepare tags a built image with that hash (localhost/solid-memo-e2e-<id>:inputs-<hash>), lets older such tags go, and skips the build when an image of the current tag is there, as it is after a load. The journeys' images are keyed by their compose file. The jobs use Docker's containerd image store, which keeps the digest a loaded image was pulled by (the classic store forgets it, and compose would pull the image again), switching to it on a runner whose Docker does not already; an image pulled by digest is saved under the tag its reference names. What the cache does not have yet (a job's first run, or after an image changes) is pulled through mirror.gcr.io, Google's mirror of Docker Hub, set as Docker's registry mirror in the same step; Docker goes to Docker Hub for what the mirror lacks. A cache that cannot be saved or loaded only warns: the job pulls or builds, as before.

They need Docker: Docker Engine 28.3.3 or later on Linux (before it, a port published on 127.0.0.1 could be reached from the local network, CVE-2025-54388), or Docker Desktop. A run killed before its teardown leaves its servers; the next run takes them down, as does npm run pod:clean. npm run pod's server lets anyone read and write: keep no real data in it, and stop it when done.

The servers differ in what they enforce, and the tests ask each rather than assume (serverTraits.ts):

Every skip says why. Everything else runs on every server. A test may skip only where the server does something the Solid Protocol allows and the app copes with, as above; what the app needs and has no way around (a PATCH it can send, an ACL it can write, every insert of several at once kept) is never a reason to skip, and a probe that cannot tell throws. What the tests need only for themselves they ask of the server too: an ACL document is where its Link rel="acl" says, and a change "another app" makes is an N3 Patch, a SPARQL Update or the whole document, as its Accept-Patch allows. What each blocking server does is pinned (serverTraits.integration.test.ts): a release that changes it fails there, not as tests quietly starting or stopping to skip, and the pin moves by hand once the change is understood. The tests of the format update and of the library upgrade check that a document a write of theirs left alone, or failed on, is as it was "byte for byte": the bytes the server serves for it, asked for as the app asks (Accept: text/turtle), before and after (migrations.md, the upgrade's).

The Community Solid Server's in-memory store (the one these tests use) cuts a document short after a PATCH to it when the document holds characters outside ASCII (å, ä, ö), even when the patch itself is ASCII: it stores as many bytes as there were characters. Its file store does not; keep test data that is patched ASCII, or measure on -c @css:config/file.json -f <dir>. A release's draft is written whole (PUT) for this, and because node-solid-server stores a boolean a PATCH inserts (owl:deprecated true) as false: its tests (releaseDrafts.integration.test.ts) take the library's largest course, its text as it is, through a draft on every server, each step within a time budget.

Publishing a release (releases.integration.test.ts) is the app's only write of access control. The tests write anonymously, as every server lets anyone write, so "no login" is what the release's own ACL (Web Access Control) or ACR (Access Control Policies, css-acp, blocking so that the ACR's write gates CI as the ACL's does) says once published: the test reads that document, not only the release. A second release at the same address is refused, and leaves the first as it was. A publishing cut short after the release is written (its catalog link refused once) is finished by publishing again; deleting the instance then keeps the release and its folder, and says so (keptReleases). node-solid-server refuses the PATCH @inrupt/solid-client writes an ACL with, and the Community Solid Server answers an ACR not written yet with 404; the publisher takes both in its stride (accessFetch). Solid-Nextcloud names a folder's ACL as each of its documents', so the release stays private there (its expected-failures.json).

solid-server 6.0.0 is packaged with faults its image works around: it lacks the root ACL template it copies on first start (so the image has one in its config folder), and it lists its own commit-hook tool @fastify/pre-commit as a dependency, whose install script would put a git hook in place; 5.8.8 does too. The image installs without install scripts. node-solid-server prints every request it handles (solid:*), so its log runs to tens of megabytes.

The vocabulary, the deck library and the pod catalog fixtures are also cross-checked by pySHACL in CI (npm run crosscheck; see validation.md). The script has tests of its own, with Python's standard unittest (scripts/test_shacl_crosscheck.py, npm run test:crosscheck): each builds a small repository in a temporary folder, a few lines of Turtle in place of DCAT-AP, SkoHub's shapes, the deck library and the fixtures, and runs the script's checks on it, so they read none of the real documents and need no network. They cover how it picks and targets shapes, where it stops (a shape DCAT-AP adds, rdfs:subClassOf, a subject with no shape) and what it reports when a document fails. CI's crosscheck job runs them before the cross-check; npm run check does not, as it needs no Python.

User journeys

e2e/journeys/ drives the built app (npx turbo run build --filter=@solid-memo/web, served by vite preview at http://127.0.0.1:4173/, the Studio at #/studio) in Chromium, and a few of them in Firefox, WebKit and on a phone (below), with Playwright, through whole journeys a user takes: logging in with a WebID at a real identity provider, setting preferences, making and studying decks, grouping them, importing from the library, starting the library's course for newcomers from an empty deck list and working through its first chapter and final review, describing a deck in two languages, validating the instance, logging out, and opening the Studio to manage an instance's decks and its drafts of releases, writing a course in one and test-playing it in the trial, the instance's decks and statistics unchanged after, then publishing it, public, which someone with no login then reads, and another user adds from its link and plays. Each journey runs against a fresh account, pod and WebID on a Community Solid Server 7 (css/compose.yml) that the global setup starts in Docker and takes down after. The app logs in only with https WebIDs and issuers, so the server sits behind Caddy at https://127.0.0.1:<port>/ with Caddy's own certificate, which the browser is told to accept. The server shares Caddy's network, so it reaches its own https address too (to read the WebIDs it checks tokens for). It keeps its pods in files, not in memory, because the journeys PATCH Swedish text (see above). The journeys need Docker and the browsers (npm run browsers -w @solid-memo/e2e-journeys installs Chromium, Firefox and WebKit, with the system libraries they need), and CI runs them on every pull request and every push to main. They block, as the blocking servers do.

Further journeys go through:

Browsers

Every journey runs in desktop Chromium, the chromium project in playwright.config.ts. Three more projects run only the journeys tagged @smoke, @guest and @reload: firefox (desktop Firefox), webkit (desktop Safari's engine) and phone (an iPhone 14's screen and touch, on WebKit). Those journeys go through what differs most between engines: the login's redirects to the identity provider and back, a guest's study kept in the browser's storage through them, the silent restore of a session after a reload, and a page narrow enough to lay out for a phone. The other journeys test the app's logic, which is the same in every engine, so running them all in each would about triple the time for little gain. A journey tagged with one of the three runs in every browser; tag another only when what it tests differs between engines. CI runs Chromium's journeys in one job and the other three projects' in another (journeys (chromium), journeys (engines)), so neither takes much longer than one job did. The user guide says which browsers Solid Memo supports.

The engines differ in three ways the journeys allow for:

WebKit on Linux needs system libraries that npm run browsers installs with sudo. Without them, the webkit and phone projects run in Playwright's Docker image of the same version (mcr.microsoft.com/playwright:v<version>-noble), with --network host and JOURNEY_SERVER_URL pointing at a server that npm run css -w @solid-memo/e2e-journeys keeps up.

The journeys must run the same way every time. Each page's Math.random is seeded (the seed is in the report; JOURNEY_SEED=<n> replays it). The browser's time zone is one where it is about noon, far from the day boundary; the later-day journey moves the zone past the boundary itself, since the Solid server refuses a DPoP proof more than two minutes off its clock, so the browser's clock cannot move instead. Requests to any host other than 127.0.0.1 fail the journey. A journey never asserts which card a shuffled queue shows, only how many there are.

Accessibility. Each step's end checks the screen it leaves the app on with axe, in the light and the dark theme, against WCAG 2.0, 2.1 and 2.2 at levels A and AA (App.step, harness/accessibility.ts). A violation fails the journey, which goes on to its end so that every screen is checked. A violation let pass is listed in that file's KNOWN_EXCEPTIONS, with its reason. The Solid server's own pages, its login, are not checked. What the app aims for, and what else is checked, is in accessibility.md.

When a journey fails, the CI job's summary names it, the step it failed in (the spec's numbered step › the page object's action) and the error, and the browser it failed in. The job's journeys-<chromium|engines>-<attempt> artifact holds everything else:

The latest report is online at https://solid-memo.com/journeys/, published with each deploy: the Chromium job's report of the CI run on the release commit the site runs (none after a deploy by hand). It shows every journey's steps and screenshots, and its Trace link opens the step-by-step timeline in the browser (the trace viewer is part of the report and needs it served, as it is there, not opened as a file). On main the journeys always keep their traces for this. The report is public and holds the throwaway accounts' passwords and tokens, which die with the run's server, and it is kept out of search engines (robots.txt). Since only a green run on a release commit deploys, a failure on main is read from the run's artifact, as is the report of a commit between two versions.

A run by hand (Actions › CI › Run workflow) with trace checked keeps traces for passing journeys on any branch. Locally, JOURNEY_TRACE=on npm run journeys does the same, and npm run journeys:report -w @solid-memo/e2e-journeys serves the report so its traces open.

Writing a journey

A journey is one file, e2e/journeys/journeys/<name>.journey.ts, with one test whose steps are numbered as the journey's spec words them:

test("a learner's first deck @smoke", async ({ app, account, runId }) => {
  await app.step("01 · Visit Solid Memo", () => app.onboarding.visit());
  await app.step("02 · Log in with a WebID", () => logInAndCreateInstance(app, account, `Instance ${runId}`));
  await app.step("03 · Create a deck", async () => {
    await app.decks.openDeckCreator();
    await app.deckCreator.create(`Deck ${runId}`);
  });
});

The harness's own code (harness/) has unit tests (npm test, so npm run check). The page objects and journeys are themselves tests, run by npm run journeys, and have no coverage of their own, as with e2e/pod.

Continuous integration

ci.yml runs on every pull request, a fork's included, in the merge queue (below), on every push to main, and by hand. A branch's pushes alone run nothing, so a pull request's commits are not checked twice for one push. Its jobs, side by side:

JobWhat it checks
buildnpm run check:ci, which is npm run check without the deck library (the library job checks it), then npm run build, with Chromium's headless shell from GitHub's cache or installed (.github/actions/chromium) for the docs' diagrams; the coverage of every package goes to the run's summary (npm run coverage:summary). Turbo's cache is kept between runs under a key of this job's own, so a task whose inputs have not changed is not run again; only a run on main saves it, and a pull request and the merge queue start from main's
librarythe change against its base: the deck library, no published version edited or removed, and every new version naming the library's contact (deck-library.md); the public API, no line of api/ gone unless a commit says the change breaks, and none of api/frozen/ at all (npm run check:breaking, releasing.md)
crosscheckthe cross-check script's tests, then the vocabulary, the deck library and the pod catalog documents, by pySHACL (validation.md)
pod (<server>)the end-to-end tests, one job per blocking server (above)
contract (<server>)that each advisory server starts and meets the contract
journeys (<browsers>)the user journeys: every one in Chromium, and those that differ between engines in Firefox, WebKit and on a phone (Browsers)
docsthe docs' links, every file's license, and publiccode.yml (Documentation); their style and spelling are build's
commitson a pull request, that each commit it adds follows Conventional Commits, by commitlint (CONTRIBUTING.md), and that its body or a commit closes its issue, or the body says it is one step of it (npm run check:closes; not for Renovate's); in the merge queue or on a push to main, nothing
cachesnothing: on main, after build, it deletes main's older Turbo caches, the newest holding what they do. The one job whose token can write (actions: write, to delete caches); it runs none of the repository's code, and its failure fails neither the run nor ci-ok
ci-okthat every job above passed, caches aside

ci-ok is the one check a merge to main waits for: the ruleset on main (a setting of the repository, Settings › Rules) is to require it and nothing else, and Renovate merges its updates through a pull request once it passes (automergeType: 'pr' in renovate.json5), as anyone's change is merged. It runs even when a job before it failed, and fails unless every one passed. A matrix job's checks are named per server, and jobs come and go, so the ruleset names this job alone. (Another workflow's job named build, deploy.yml's, would also answer to a required check of that name.) Deploys follow a green run on a release commit on main (deployment.md). Every check of ns/ and decks/ runs here, the comparison with the base included, so ci-ok gates them all and nothing is checked twice.

The merge queue

A pull request is merged through GitHub's merge queue, never straight to main: the ruleset on main requires it, in place of a branch up to date with main. Once the pull request's own ci-ok passes, "Merge when ready" (or Renovate's automerge, which turns on GitHub's auto-merge) adds it to the queue. GitHub then builds a branch, gh-readonly-queue/main/…, of main, the pull requests ahead of it and this one, and runs ci.yml on it (merge_group). When ci-ok passes there, main moves to exactly that commit; when it fails, the pull request leaves the queue, its timeline saying why, and the ones behind it are built again without it. So main only ever gets a commit CI passed as it lands, and no one has to bring a branch up to date with main: each pull request had to be, and CI run on it again, before it could merge, and again whenever another merged first. The queue merges with a merge commit, so a pull request's commits keep their SHAs on main, and the merge commit names the pull request.

A queue run checks what a pull request run checks, with two differences: commits lints nothing, the commits having been linted in their pull request, and the base library compares with is the commit of main the queue built on, so the pull requests ahead are part of the change. Each queue branch is its own concurrency group, and release.yml and deploy.yml follow push runs on main alone, so a queue run releases and deploys nothing.

A newer run for the same pull request or branch cancels the one still going, so a pull request pushed to again is checked once, at its latest commit. Runs on main are never canceled: a release follows a push run on main that passed (releasing.md), and the deploy follows one on the release commit (deployment.md), so a canceled run would hold either back. Each event has its own concurrency group, so a run by hand does not cancel a push run, and push runs on main go one at a time, in order. A push waiting its turn is replaced by a newer one, which the next release includes; a release commit's run has a group of its own, since only it can deploy its version, so no newer push replaces it.

A fork's pull request runs the same jobs, and its results are public. That is safe because the jobs only read: the token of every job it runs can read the repository and nothing more (caches, the one job whose token can delete caches, runs on main alone), no job uses a secret, and no checkout keeps the token (persist-credentials: false). A fork's run may restore the caches main's runs saved; what it saves (npm's, pip's and Playwright's, not Turbo's or the images') is kept for its own pull request only.

The job ids pod and contract, and their matrix.server lists, are read by servers.test.ts, which checks that each workflow runs the servers servers.ts says it should; keep them when changing the workflow.

The repository's own checks

npm run check starts with npm run lint: Biome fails on a TypeScript, JSON or CSS file it would format otherwise, or that breaks one of its lint rules (style.md). Then npm run lint:markdown and npm run lint:spelling check the Markdown's style and spelling (Documentation). It ends with checks of the repository as a whole, plain Node scripts in scripts/:

Documentation

The project's Markdown (docs/, the READMEs, AGENTS.md) is checked for its style and spelling by npm run check (so in CI's build job), and for its links, with publiccode.yml and every file's license, by CI's docs job:

That every page is listed in the index is npm run check's (check:docs, above).

Pod providers

Onboarding suggests the providers in podProvider.ts (POD_PROVIDERS), and logs a learner in at the issuer an entry names. The tests' Solid servers all run in Docker, so none of them is one of these. npm run check:providers (checkProviders.ts) checks each provider instead. Its issuer must answer <issuer>/.well-known/openid-configuration with JSON whose issuer is the entry's oidcIssuer, character for character, as the login compares them. Its signUpUrl, where it has one, must answer too. A request that fails or answers 5xx is tried three times. The Links workflow runs the check weekly, and by hand, in a job of its own. It is not a gate, so a provider that is down for an hour fails no one's change. A failure is an entry to fix, with the provider's new issuer, or to remove.

Without the network

Apart from the end-to-end tests' Docker images and the browsers, installed once for the journeys and the docs' diagrams, and the Pod providers' check, nothing needs the network: the vocabulary, the shapes and the deck library are read from the repository's ns/ and decks/, at the IRIs the site publishes them under.

Coverage policy

100% of every package's src/ (and tooling/ or node/ where it has one), set up once in vitest.shared.ts, with these documented exclusions:

Code that cannot reach 100% is restructured until it can (e.g. an unreachable defensive branch is removed rather than excluded). New code ships with its tests in the same change; coverage never dips.

A test may take 30 seconds, not vitest's 5, also set in vitest.shared.ts. CI runs every package's tests at once with coverage on, and its runner takes some tests fifty times as long as a laptop does. The generator's check of ns/, for example, takes 0.1 s on a laptop and ran out of 5 s on CI. A test that hangs still fails. For the same reason, a test of speed is timed against the same machine, never against the clock (see Domain and Markdown below).

Strategy per layer

Dependency inversion gives every layer a seam that makes mocks trivial:

LayerSeamTechnique
Domainnone neededPure functions; inputs (including now: Date) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes, in the CPU time of its own process, not against the clock (CI runs the tests, coverage on, many times slower than a laptop): eight times the input must take less than four times as long as an eighth of it done eight times over, which a linear pass meets (about one) and work growing with the input does not (about eight) — queryCards over 5,000 cards, lapseIndex, chainIndexOf and statisticsOf over years of answers (growth in src/testing/growth.ts).
ApplicationportsInject fake port objects (vi.fn per method). No module mocking.
UIUseCases propRender with a fake UseCases; assert via testing-library queries. Query-dependent components get a fresh QueryClient (retries off).
Infrastructure mappersnone neededPure SolidDataset/Thing → domain functions; feed in-memory datasets built with mockSolidDatasetFrom/buildThing.
Infrastructure I/O shellsinjected fetch + vi.mockMock @inrupt/* module functions; assert the shell orchestrates fetch → map → return.
Shapes (packages/shacl/, packages/solid/src/conformance.test.ts)the real engineThe real rdf-validate-shacl over the real shapes in ns/shapes/: fixture documents under packages/vocab/fixtures/ pass or fail as a table says; a record written through every descriptor, and every migration step's output, conforms (conformance.test.ts). Every library deck passes too, by npm run library:check (deck-library.md). Shape documents are read through a fetch that serves the repository's files at their IRIs (shapesFetch in sources.ts, a Response with its url set), exactly as the browser reads them.
The round trip (packages/solid/src/releaseDrafts.roundtrip.test.ts)the real draft adapterEvery release in decks/ made a draft in a pod kept in memory, as an import makes one, then published again at its own address (assemble): its triples come back, apart from what publishing sets (the format versions it writes, its time of change), a blank node compared by what it says. A task of its own, test:roundtrip, which npm run check runs and Turbo runs again only when decks/, ns/ or the package change; the unit tests take three releases (a course, one with blank nodes, a plain deck) the same way.
The library (packages/solid/src/libraryReleases.test.ts)the real library adapterEvery release in decks/ and the index, read through createSolidDeckLibrary over a fetch that serves the repository's files at their IRIs, as the app reads them: the index lists every deck, each release reads with every card it has (its retired ones retired), and a course's outline has every chapter and step it has in use, each asking cards the app can ask (a back and at least two distractors). The library check validates the releases; this is what the app makes of them. The check's rules (packages/domain/src/release/) have tests of their own, and a golden test, packages/shacl/node/deckLibrary.golden.test.ts, keeps as text what they say of published releases with faults put in.
Markdown (packages/markdown/)none neededPure functions over strings: every node in both profiles, the folds, the chunks of a step's theory, each rule for a release (markdownProblems), the limits at every entry point, and the cmark and commonmark.js pathological inputs at the length cap within a time budget (markdown.md).
The docs' diagrams (packages/markdown/tooling/)an injected launchWhat a block must have, the settings, the keys and checkSvg are pure functions (diagrams.test.ts); FONT_CHARS is held to the cmap of the installed Fredoka file, read from its WOFF2. drawDiagrams.test.ts draws through a fake browser whose evaluate(fn, arg) calls fn(arg) in node, with a fake mermaid and document.fonts, so the callbacks the drawing page runs are covered: cache hits, a hit that fails checkSvg, misses written under a temporary name, the font's notice in every drawing, a block Mermaid refuses at the page's line, Chromium missing when drawing is required and when it is optional, and DOCS_DIAGRAMS=skip, in CI too, drawing nothing and starting no browser. One test draws in the real Chromium with the official Mermaid, a flowchart, a sequence diagram and a state diagram in both themes, with a timeout of its own (120 s). It is the only test of the real launch, so under DOCS_DIAGRAMS=skip, which skips it, the package's coverage and npm run check fail (markdown.md).
The docs as a site (packages/markdown/tooling/)an injected Io and draw, a fake browserdocsSite.test.ts renders docs held in memory: each kind of link from the README and from a page, and that no address starts with /; every problem, with its file and line; escaping; the page's frame, the figure and the figure without drawings; the font's license beside the drawings and in the footer of a page with a figure; and an archive; and that every page it writes passes checkPage, which refuses what a page is not written as. It also renders the repository's own docs/ with a fake draw and checks that each page has as many links, tables and figures as its source, so a docs edit that breaks a link fails npm run check (the task's inputs hold docs/ and the Markdown at the root). publishDocs.test.ts runs the Vite plugin's build hook and its dev server's middleware with a fake browser (markdown.md).
A release's docs as an archive (scripts/docsArchive.ts)an injected Io and drawdocsArchive.test.ts packs the repository's own docs/ with a draw that makes drawings as drawDiagrams does, with the system's tar, and lists and unpacks the archive: every entry under docs/, in order, the font's license among them and the list of versions not, every drawing safe to serve (checkSvg) and carrying the font's notice, and a page linking the release's tag and never indexed. Nothing is written when the diagrams were not drawn, a drawing is unsafe or lacks the notice, or the docs have problems. The script's own draw is held to drawing in Chromium under DOCS_DIAGRAMS=skip too, and its repository address to apps/web's .env files, as the site's build reads them (deployment.md).
Each release's docs, served by the deploy (scripts/docsVersions.ts)an injected Io: the environment, GitHub's API in memory, and the attestation checkdocsVersions.test.ts packs a release's docs as renderDocsSite writes an archive, with the system's tar, and serves it from a fake API, a hundred releases a page: it is unpacked under docs/X.Y.Z/ of a built site, a draft's or a missing archive is a notice, and the list of versions is written over the build's, every tag newest first. An archive is left out, with an error, when someone else uploaded it, it has no digest or another one, its address is off the API, it cannot be downloaded, GitHub holds no attestation of it (asked of the bytes downloaded, before anything is unpacked; the default check runs gh attestation verify for the release workflow on main, tried with a fake gh), or it is not an archive, or it holds a link, a file outside docs/ or of another kind, a file twice, no front page, drawings without the font's license, or a page, stylesheet or drawing that its check refuses; and when its folder is in the build already. An API that cannot be read leaves every version linked in the repository. The script asks for its folder, DOCS_TAGS and a repository on GitHub (deployment.md).
The built site (apps/web/src/build.test.ts)Vite's build APIBuilds the site as vite build does, without writing it, and checks the bundle has no HTML sink but Preact's own, the Studio is a chunk the page loads only lazily, what the page loads up front (its entry and the chunks that imports) is at most UP_FRONT_BUDGET_KB of JavaScript gzipped (573 kB in October 2026, against 600; past it, the test names the largest modules, and a change that needs the weight raises the budget, saying why), and the page's Content Security Policy still lets its inline script run (markdown.md). It also checks the docs the build publishes: a page for each docs/*.md, each with the docs' policy before anything it loads, no script, style or event handler, one heading of level 1, a title, a description, its canonical address and ids of its own, docs.css its one stylesheet; no address from the site's root or to a Markdown file; every link leading to a heading, a page or file of the site, a file or folder of the repository at the commit built, or an http(s) address; a figure for each Mermaid block, its light and dark drawings safe to serve (checkSvg) and carrying the font's notice; the font's license beside them, as the package has it; the sitemap listing every page; and every address publiccode.yml names on the site's domain, its documentation among them, a page the build serves. It draws in the real Chromium, as the build does; under DOCS_DIAGRAMS=skip it checks that every figure shows its source instead, and nothing is drawn. The task's inputs hold docs/, ns/, decks/, CNAME and the root's package.json, which the build reads, and publiccode.yml.
Generated codedrift testpackages/vocab/tooling/generate.test.ts renders the generators' output and compares it with the committed files; generated modules are data only, so importing them covers them (packages/vocab/src/generated.test.ts).
End to endreal Solid serverse2e/pod/ wires the real use cases and Solid adapters as createAppUseCases does, over a fetch that records every request, against Community Solid Server 7 and 6 and node-solid-server 6 and 5.
User journeysthe built app in a browsere2e/journeys/ drives the app in Chromium (Playwright) through whole journeys, logging in at a real identity provider (Community Solid Server 7 behind TLS) (User journeys).
How each layer is tested
Pure functions are tested with plain calls, use cases with fake ports, components with fake use cases, mappers with in-memory datasets and I/O shells with mocked Inrupt libraries.
The diagram's source
graph LR
    accTitle: How each layer is tested
    accDescr: Pure functions are tested with plain calls, use cases with fake ports, components with fake use cases, mappers with in-memory datasets and I/O shells with mocked Inrupt libraries.
    D["domain<br/>pure fns"] -->|plain calls| T1[tests]
    A["application<br/>use cases"] -->|fake ports| T2[tests]
    U["ui<br/>components"] -->|fake UseCases| T3[tests]
    M["infra mappers<br/>pure fns"] -->|in-memory datasets| T4[tests]
    S["infra I/O shells"] -->|vi.mock @inrupt/*| T5[tests]

Conventions