Skip to content

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_PATHDocExamples, 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 under Postaci/NPC/Werbin/;
  • a canonical-header session in Świat gry/Werbin/Sesje lokalne.md, crediting Roman 0,15 PU;
  • a nerthus.pu-sessions.md ledger.

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

  1. 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 the It after the contract ('aborts the whole batch on one unresolved name').
  2. Add one contract-layer route test (one success, one 403).
  3. 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 SIGKILL leaves one file mutated; git status shows it and git checkout -- <file> is the repair. tests/CallSiteAssertion.Guard.Tests.ps1 skips rather than mutating when ci/agent or daemon is 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