Run the tests¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Work on Nerthus.Platform.
This guide shows how to run the test suites, what each suite covers, how the fixture repo works, and the mock policy every new test must follow. The PowerShell suites run under Pester v5 from the module root; the C# substrate carries its own xunit suite beside them.
Run the suites¶
Build the C# substrate first. Initialize-NerthusCSharp loads the prebuilt assembly at lib/Nerthus.Core.Substrate.dll and throws, naming the build command, when the file is absent — so on a fresh clone nothing under tests/ runs until the DLL is in place:
dotnet build src/Nerthus.Core.Substrate/Nerthus.Core.Substrate.csproj -c Release
New-Item lib -ItemType Directory -Force | Out-Null
Copy-Item src/Nerthus.Core.Substrate/bin/Release/net9.0/Nerthus.Core.Substrate.dll lib/
Invoke-Pester ./tests/ -Output Detailed
Ship the net9.0 build: pwsh 7.5 runs on .NET 9, and a DLL built for a framework the host cannot load registers no types, which Initialize-NerthusCSharp also refuses by name. CI's build:substrate job makes the same copy and hands the DLL to every job that boots the daemon.
The module's GitLab pipeline runs the same suites on every push - see Set up pipelines.
The wrapper, and the four things it refuses¶
Invoke-Pester above is the direct form. The supported entry point, and the one the pester job
runs, is tests/Invoke-NerthusSuite.ps1:
pwsh -File ./tests/Invoke-NerthusSuite.ps1 -Output Detailed
pwsh -File ./tests/Invoke-NerthusSuite.ps1 -Path ./tests/Search.Tests.ps1
pwsh -File ./tests/Invoke-NerthusSuite.ps1 -KeepTemp # leave the run directory for inspection
It points TMPDIR at a directory of its own for the duration, so every fixture root the run builds
lands inside it and one removal is the whole cleanup, and it prints the leftover count on every run.
It then refuses four states that Pester's summary line reports as a pass:
| Exit | What it caught | Why the summary line cannot show it |
|---|---|---|
2 |
a container failure — a file that threw in discovery, or whose BeforeAll died |
the cases that would have raised total are exactly the ones that never came into being, so Passed: 9, Failed: 0 is printed over a file that did not run |
3 |
TotalCount is 0 — a renamed tests/, a checkout where the tests never materialised, a -Path typo |
Pester has nothing to fail |
4 |
a block that finished with nothing passed and nothing failed | a Describe whose cases all skipped reports Passed; measured on a two-case fixture, total=2 passed=0 skipped=2 Result=Passed, exit 0 |
1 |
an ordinary test failure, when -Exit was passed |
— |
Exits 2, 3 and 4 are unconditional. There is no switch that relaxes them, on purpose: a guard
behind a flag defaulting to off is a guard nobody runs, and one behind a flag defaulting to on is
one a red pipeline gets turned off by.
Exit 4 is the one you will meet. 31 cases skip for real reasons — a sibling checkout, a
platform, an opt-in corpus — and those blocks are named in
tests/vacuous-declared.psd1, one row each with a reason, keyed on the path from the module root. A
block that is not on that list and measured nothing fails the run, and the message prints the row to
paste.
The suite total is deliberately not quoted beside that 31: it moves on every test anyone adds, and
it moved from 3 145 to 3 154 inside the change that first wrote it here. Read the job's own
test_report_summary when you need it.
The same file carries eight shapes through which an assertion can evaporate without any reported
number moving — the -Skip families, -ForEach over a set that can come out empty, Set-ItResult,
floors a count cannot violate, and CI script checks — each with a verdict of honest,
declared-skip or defect. It is not every such site, and the file says so: a plain foreach
inside an It is a ninth shape that neither the runner nor the sweep can see, so it is carried as a
counted population with an owner rather than as rows. tests/Vacuous.Guard.Tests.ps1 holds the
ledger to the tree in both directions, re-runs the sweep, and asserts those counted populations
against the code that derives them — so neither a row nor a number can stop matching quietly.
Point the suite at its siblings first¶
Seven suites read NERTHUS_DOCS_PATH or NERTHUS_DASHBOARD_PATH — DocExamples,
RouteCoverage.Guard, DocJson.Guard, DocDestinations.Guard, GitLabProxy, RouteReach.Guard
and ChangeLog — and the smoke suite reads the infra pair. On a checkout that stands alone, what
each of them asserts is in the tables below rather than in this sentence. Export these before a
full run:
$env:NERTHUS_DOCS_PATH = '<path to nerthus.docs>'
$env:NERTHUS_DASHBOARD_PATH = '<path to nerthus.core.dashboard>'
$env:NERTHUS_INFRA_DEV_PATH = '<path to nerthus.core.infra.dev>'
$env:NERTHUS_INFRA_COMMON_PATH= '<path to nerthus.core.infra.common>'
Each variable set to a path that is not there is a hard failure, deliberately: a mistyped path is the case where a warn-and-skip would be a green run over an assertion nobody made. What the message then carries is tabulated below, per message, because it is not the same three facts every time. Left unset, each falls back to a sibling directory beside this repository, and if that is not there either, what happens depends on the sibling and on the suite — which is what the tables below are for.
A total that did not move is not a suite that ran
The three fallbacks are not equally visible, and one of them is invisible:
| Sibling absent | What happens | What total does |
|---|---|---|
nerthus.docs |
DocExamples warns and skips; RouteCoverage.Guard, DocJson.Guard and DocDestinations.Guard are -Skipped; GitLabProxy skips 4 of its 61 and runs the rest; ChangeLog fails 2 |
drops by 206 for the first — 210 to 4, one per example, with the four per-page census cases surviving as skips; unchanged for the three -Skipped — their Its are static and move from passed to skipped |
nerthus.core.dashboard |
RouteReach.Guard throws at discovery; DocDestinations.Guard is -Skipped |
zero tests from the first, and the run reports Container failed: 1; unchanged for the second |
nerthus.core.infra.dev / .common |
the smoke suite's edge-route comparison skips with a named reason | unchanged |
A sibling that is present is a case of its own, and not a fallback that always works. A
fallback path carries .. segments, and Test-Path resolves those on the way past without
rewriting the string it was handed — so an unresolved root can reach a Substring and index off
the end of a resolved FullName, which reports as startIndex cannot be larger than length of
string from inside discovery and costs the whole file. Every root that gets subtracted from,
and every path that is printed in a refusal, is resolved with GetFullPath where it is built
in RouteReach.Facts.ps1, RouteCoverage.Guard, DocJson.Guard and DocDestinations.Guard.
That is the narrow claim and it is the one that matters: a path only tested and read carries a
.. harmlessly. GitLabProxy.Tests.ps1 builds its two at :715 and :732 with Combine and
falls back correctly, because it opens the file and never subtracts a root from it.
A guard has four arms and not two. Measured 2026-08-23 at Nerthus.Core ca279b5 plus the
commit that adds the last row, Pester 5.7.1, against the nerthus.docs and
nerthus.core.dashboard checkouts beside this repository. Cells are passed / failed / skipped:
| Suite | Exported, tree there | Exported, tree not there | Unset, no sibling | Unset, sibling present |
|---|---|---|---|---|
RouteCoverage.Guard.Tests.ps1 |
6 / 0 / 0 | throws at discovery | 0 / 0 / 6 skipped | 6 / 0 / 0 |
DocJson.Guard.Tests.ps1 |
6 / 0 / 0 | throws at discovery | 0 / 0 / 6 skipped | 6 / 0 / 0 |
DocDestinations.Guard.Tests.ps1 |
3 / 0 / 0 | throws, per variable | 0 / 0 / 3 skipped | 3 / 0 / 0 |
RouteReach.Guard.Tests.ps1 |
142 / 0 / 0 | throws at discovery | throws at discovery | 142 / 0 / 0 |
ChangeLog.Tests.ps1 |
24 / 0 / 0 | 22 / 2 / 0, named | 22 / 2 / 0, named | 24 / 0 / 0 |
The two middle columns are the arms a two-arm table cannot see, and they are not the same
arm. Column two is a mistyped or dead export, and a skip there is the failure this whole
section is about: Result=Passed, FailedCount zero, and PassedCount zero because nothing
ran. Column three is a contributor with one repository — who is owed an honest answer, and gets
a skip from the first three rows, a refusal from RouteReach.Guard, and two failed cases from
ChangeLog.Tests.ps1.
Those last two rows do not skip in column three, and neither is an accident.
RouteReach.Guard's census means nothing without a client to read, so it refuses rather than
reporting a ratchet satisfied by an empty set. ChangeLog.Tests.ps1 fails, on the argument its
own comment makes: a guard that disappears when a variable is unset protects everything except
the case it exists for. So a lone checkout is red, in two named places rather than quietly
green.
DocDestinations.Guard reads two checkouts, so it refuses per variable. Export
NERTHUS_DOCS_PATH at a path with no explanation/dashboard.md and the message names that
variable and that file; export NERTHUS_DASHBOARD_PATH at a path with no
src/ui/route-table.ts and it names the other. A refusal that named the wrong side would send a
reader to a checkout that is fine.
What a message carries is not uniform, and a reader chasing one should know which facts to expect. The three worth having are the variable, the value it was given, and the path that value resolved to. Read off each message at runtime, same tree and command shape as the table above:
| Message | Variable | Its value | Resolved path |
|---|---|---|---|
DocExamples |
yes | no | yes |
RouteCoverage.Guard |
yes | yes | yes |
DocJson.Guard |
yes | yes | yes |
DocDestinations.Guard, docs side |
yes | yes | yes |
DocDestinations.Guard, dashboard side |
yes | yes | yes |
GitLabProxy |
yes | no | yes |
RouteReach.Guard |
yes | yes | yes |
ChangeLog, first case |
yes | no | yes |
ChangeLog, second case |
no | no | yes |
ChangeLog's first case fails a Should -BeTrue whose -Because names the variable and the
path it looked at. Its second case carries no -Because at all: it is the raw ReadAllText
exception, which names the path it could not open and nothing else.
The arms in which these guards run were checked against a planted defect rather than trusted
for being green.
DocJson.Guard catches a duplicate key added to a response body and names the file, the
line and the JSON path. DocDestinations.Guard catches a destination added to the router
and not to the page, and a two-segment address named on the page and not in the router —
and it is blind to a one-segment one, because /:names absorbs every one-segment path
by design, which is stated in the file.
Every one of the three has been caught passing over something real. RouteReach was landed
with no dashboard checkout anywhere in CI, and for as long as that was true it reported seven
passing tests over nothing at all — its untriaged ratchet comparing 0 against 0. The
ratchet was satisfied by not having the checkout, in every worktree and in every pipeline it
had ever run in, and the low number was the only one it had ever produced, so there was nothing
to compare against. It now refuses instead: the facts it reads throw, Pester reports a container
failure, and the run's exit code carries it.
The edge-route comparison is the one that was already failing, and the gap has widened
since: the route table declares 51 replica-safe GETs and the edge list still carries
21 (measured 2026-08-28, live GET /routes on dev-torneg against
Nerthus.Core.Infra.Common@7cb02cb worker/replica-routes.js). It read 40 against 21 on
2026-08-23. The assertion that says so had been skipping in every worktree, so nobody saw it.
That one is still quiet, and so is column three of the table above, which is quiet on purpose. The standing advice holds for both: read the number
of tests that ran, not the number that passed, and compare it against a run you trust.
Know the suites¶
tests/ holds 101 *.Tests.ps1 files at its top level plus five more under contract/,
integration/ and e2e/ (Nerthus.Core@5ae415e, 2026-08-28), and Pester recurses the lot. This
sentence said 43 until now; that figure was written on 2026-08-04 and was never re-taken. A count
of files in a growing tree has to name its commit or it means nothing.
Four suites are worth knowing by name:
| Suite | File | What it proves |
|---|---|---|
| Smoke | tests/Nerthus.Smoke.Tests.ps1 |
Every subsystem end to end at the service level - the C# substrate, the Markdown parser, the per-subsystem services against fixture bytes |
| Sync | tests/Sync.Tests.ps1 |
The in-daemon git sync: scheduler tick (converge and publish), mr mode, the substrate probe and status service, the write gate's SyncStale/SchemaTooNew refusals - real disposable git fixtures, self-skips when git is absent |
| Contract | tests/contract/Api.Contract.Tests.ps1 |
The wiring: each Describe spawns a real nerthusd on a fresh fixture clone, adopts it over POST /import, drives routes with raw HTTP, tears down in AfterAll (POST /admin/shutdown, kill fallback) |
| Adoption rehearsal | tests/integration/Adoption.Rehearsal.Tests.ps1 |
The pre-deployment gate against a real, disposable clone of repozytorium-fabularne - env-gated, see below |
Contract assertions are status codes, envelopes, capability 403s, dry-run no-write, the token lifecycle, and the middleware order - 401 before 404 before 403 before the write gate, as specified in the API reference. Contract tests stay targeted at wiring; exhaustive logic coverage belongs in the service layer.
The smoke suite is the authoritative coverage inventory. Its Describe names read as contract statements: Temporal scalar writes (history is never destroyed), Session edit preserves custom narrative content, Token store (hashed ids, no raw values on disk). To see what covers a subsystem, grep the test file for that subsystem's vocabulary.
The substrate suite¶
tests/Nerthus.Core.Substrate.Tests is not Pester: it is an xunit v3 suite over the C# substrate, run locally with dotnet test Nerthus.Core.sln. CI's dotnet-test job builds the solution and runs the suite as a self-executing binary on both of its target frameworks, net9.0 and net10.0. A leg that discovers zero tests fails the job — the xunit runner exits 0 on an empty run, so the job asserts on the test count instead.
The guards¶
Twelve *.Guard.Tests.ps1 files assert a property of the tree rather than a behaviour of the
daemon. They are cheap, they hold no fixtures, and each one exists because something drifted apart
once. Four are about the route table alone, and it is worth knowing which question each answers,
because a route can pass three of them and still be unusable:
| Guard | The question |
|---|---|
RouteTable.Guard |
is every row the row the daemon's own matcher picks for a path built from that row's pattern? A literal route appended after a same-method same-arity parameter route is unreachable, and this is what says so. It also holds the row count against the number of Handle-* definitions, so a row without its handler is red |
RouteCoverage.Guard |
do the table and the published API reference carry the same rows, with the same capability and the same write flag? |
RoutePool.Guard |
does every GET row state whether it may be answered on a reader thread, and no other row state it at all? The dispatcher checks the method before the row, so the flag on a POST row is inert |
RouteReach.Guard |
is every GET route reached by the browser client, or declared unreached with a reason from a closed set? |
DocJson.Guard |
does every fenced json block in the documentation parse, and does any object in one declare the same key twice? ConvertFrom-Json keeps the last copy of a duplicate and raises nothing, so a round-trip agrees with itself while a reader takes the other value — this walks a JsonDocument, which keeps both |
DocDestinations.Guard |
does explanation/dashboard.md name every address the dashboard's router carries, and no address it does not? Six patterns had drifted onto the router and onto neither of that page's two tables, four of them the language layer's whole operator surface |
The rest guard the image's binaries, the shipped assembly set, the fixture generator, the mention artifact's identity, the memo container, the staged overlay's scope, GitLab project paths and the merge drivers.
The adoption rehearsal (env-gated)¶
Set NERTHUS_REHEARSAL_REPO to the path of a disposable clone and run the file - it spawns a daemon, previews then applies the import, runs the integrity quartet (entities, sessions, currency reconciliation, override lint), and proves the re-import is byte-identical. Without the variable the whole file skips, so CI runs it as a no-op.
Warning
Never point NERTHUS_REHEARSAL_REPO at a working copy you care about - the rehearsal adopts for real.
Use the fixture repo¶
The smoke suite's BeforeAll dot-sources the full daemon service set (daemon/services/Sync.ps1 included: it defines Invoke-NerthusGit, and a service that cannot resolve that wrapper takes a fallback path silently - the annotation digest's committed-tree identity is the one that bites), loads the prebuilt C# substrate from lib/ (there is no PowerShell fallback), and builds a miniature, real-shaped Polish fixture repo in a temp directory via the shared builder tests/FixtureRepo.ps1 (New-NerthusFixtureRepo - the same fixture bytes feed the contract suite):
- a seed
Gracze.md(consumed by the import's bootstrap path) - player, the PU triple (STARTOWE/SUMA/NADMIAR), two narrator-note bullets, Margonem id, Discord channel; - a
Postaci/Gracze/character sheet and an NPC underPostaci/NPC/Werbin/; - a canonical-header session in
Świat gry/Werbin/Sesje lokalne.md, crediting Roman0,15PU; - a
nerthus.pu-sessions.mdledger.
Every repo a test creates is deleted in its paired teardown.
Boot a daemon through the shared wait¶
Four callers spawn a real nerthusd and wait for it: the contract suite, DocExamples, the
adoption rehearsal, and the lore repository's CI drivers. All four go through
Resolve-NerthusDaemonEndpoint (daemon/DaemonEndpoint.ps1), which polls
.nerthus/runtime/daemon.port and daemon.token, waits for a 200 from /health, and returns
@{ Repo; BaseUrl; Token; Port; DaemonPid }.
$P = Start-Process -FilePath (Get-Process -Id $PID).Path `
-ArgumentList @('-NoProfile','-File', $Boot, '-Repo', $Repo) `
-RedirectStandardOutput $OutLog -RedirectStandardError "$OutLog.err" -PassThru
$D = Resolve-NerthusDaemonEndpoint -Repo $Repo -Process $P -OutLog $OutLog -Description 'Contract daemon'
Two values in it are the reason it is shared rather than copied. The per-attempt HTTP timeout is
twenty seconds, because a daemon that is alive and still building its index answers nothing inside
a two-second window: every attempt times out and the wait then reports a boot that never happened.
The overall deadline is what bounds the wait, and it is the only knob a caller normally moves
(-TimeoutSeconds; the rehearsal uses 180 for a real lore clone).
The other is what a failure says. The exception carries the exit code when the spawn died, the last
thing /health did, and the tail of the boot's own stdout and stderr - read while the log still
exists, since the suite wrapper removes its run directory before CI collects artifacts. A copy of
this loop somewhere else is a copy that will disagree about both; tests/DaemonEndpoint.Tests.ps1
sweeps tests/, ci/ and daemon/ for one.
Follow the mock policy¶
Every test redirects one seam - the repo root (services take -RepoRoot / a $Daemon hashtable pointed at the fixture) - and leaves the data path real.
| Concern | In tests | Why |
|---|---|---|
| Repo root | the temp fixture repo | The single isolation seam |
| Clock / "now" | fixture dates chosen so "now" never matters, or explicit -ActiveOn/-At |
Monthly PU and temporal projection must be deterministic |
| Discord delivery | never enabled | No real Discord bot API calls in tests |
| Game-log fetch | file:// fixture URLs |
No live host calls |
| Discord JWKS | a locally generated RSA key passed straight to the verifier (-Jwks) |
Verify the real RS256 signature path without the network |
| Markdown parser | REAL (Nerthus.MarkdownScanner on fixture bytes) |
It is the behavior under test |
Nerthus.* C# types |
REAL (all of them, via Add-Type) |
No shadow exists |
| .NET statics | REAL ([System.IO.File]::…) |
Fixtures are the input; mocking them tests nothing |
| Name index | REAL (built from fixture entities) | Resolution stages must run against true tokens |
A resolution test that mocked the declension engine would prove nothing about Polish declension; it must feed Solmyrze to the real engine and assert it lands on Solmyra - see Name resolution.
Copy the proven patterns¶
Write tests assert on the bytes written back, never on return values alone, and always cover the read-only refusal:
It 'refuses to author in read-only mode (write gate)' {
$script:SD.Mode = 'ReadOnly'
{ Add-NerthusSessionService -Daemon $script:SD -File 'Wątki/RO.md' -Date '2025-06-02' -Title 'X' -Narrator 'Y' -Apply } |
Should -Throw '*SchemaTooOld*'
}
Importer tests assert the idempotency contract directly: read the emitted nerthus.entities.md with [System.IO.File]::ReadAllBytes(), re-run the import, and assert the bytes are identical - see Adoption for the contract.
PU math uses [decimal] to match real values (0,15 parses to [decimal]0.15; grant decimals are kept as-is).
Add tests for a new route + cmdlet¶
- Add service tests to the smoke suite (or a sibling
*.Tests.ps1) against a fixture repo - happy path, edge cases, read-only refusal, Polish-diacritic data. Name theItafter the contract ('aborts the whole batch on one unresolved name'). - Add one contract-layer route test (one success, one
403). - Keep the API reference in sync - a new route or cmdlet missing from the canonical cmdlet ⇄ route mapping is a doc bug.
The full feature recipe is in Add a feature.
Assert the call sites, not just the helper¶
A guard's callers need asserting - one assertion per call site. The rule has been restated after every project that broke it, and four projects broke it in the week of 2026-08-10: each tested its new predicate thoroughly and shipped call sites that no suite would miss if they stopped calling it. In one case the reviewer reverted all four call sites one at a time and the suite stayed green on every one.
Checking it costs a command:
pwsh -File ./ci/agent/Invoke-NerthusCallSiteMutation.ps1 `
-Function Test-NerthusNameIndexed -SourceRoot ./daemon `
-TestPath ./tests/NameFilter.Guard.Tests.ps1, ./tests/Normalization.Tests.ps1
It finds the call sites by AST (so a mention in a comment or a string is not one, and a splatted
call is), neutralises each in turn, and runs the suites you named. A call used as a statement is
replaced by $null; a call used as an expression is replaced by $true and by $false,
because which polarity is the dangerous one is not knowable from outside the call site. A mutant
that leaves the suites green SURVIVED - nothing distinguishes that call site doing its job from
that call site not being there.
Two things about the output are worth knowing before you read a survivor as a defect:
- The verdict is relative to the suites you name. The same call site can survive against the guard file written for it and be killed by a broader suite that exercises the route. A survivor says "these suites do not reach it", which is a fact about the list as much as about the code. What it is not is a verdict over a suite that failed to run: before mutating anything the harness runs each named suite alone, then all of them together, and refuses to continue unless every one contributed cases, all are green, and the combined total is their sum. A mutant run is read the same way — KILLED needs a failing test, not a non-zero exit code, because a suite that never ran also exits non-zero. This is a repair, not a precaution: the first version of the harness decided from the exit code and reported every mutant KILLED over runs of zero tests.
- The harness mutates the working tree and restores it, so it refuses to run on a dirty tree.
A run killed with
SIGKILLleaves one file mutated;git statusshows it andgit checkout -- <file>is the repair.tests/CallSiteAssertion.Guard.Tests.ps1skips rather than mutating whenci/agentordaemonis dirty — so a local run mid-edit tells you nothing, and only a run on a clean tree (which is what CI has) is evidence.
Predicates declared in tests/call-site-assertion-declared.psd1 are checked on every run by
tests/CallSiteAssertion.Guard.Tests.ps1. Add yours there when you ship a guard - a tool that has
to be remembered is the same failure one level up.
Avoid the forbidden patterns¶
Should -BeIn @(200, 403)on a write route's status - it green-lights both the intended and the accidental outcome. Assert the exact code.- Mocking the parser, a
Nerthus.*type, or a .NET static. Feed them fixture bytes instead. - Leaving a daemon or temp repo behind. Every setup pairs with its teardown.
- Asserting on call counts / internal variable names. Test behavior (input → bytes / status / owner), not implementation.
- Real network egress - no live Discord bot API, game-log host, or Margonem key in any test.
- Asserting a hand edit survives inside a generated
Sesje.md. The session registry is machine-owned: overwriting the edit is the contract there, so such a test asserts the opposite of the specified behavior.
See also¶
- Architecture - the daemon-as-data-owner contract and the C# substrate
- Adoption - importer idempotency, preview == apply
- PU model - fail-early, cap/overflow math, the dated timeline
- Sessions model - the universal header key and entity-driven distribution
- API reference - the route table, envelopes, and middleware chain that contract tests must track