How much of the xunit mesh-test estate can move in-mesh
Measured on MeshWeaver.Plugins commit 57d84019 (2026-09-01), read-only. No product code was
changed and no suite was converted; this page is the classification that sizes the migration.
The rule that decides the shape
"anything which is a plugin surely does not stay xunit. test process is 1) take image, 2) install plugin, 3) execute test code" — maintainer, 2026-09-01
That splits the estate in two, and the split matters more than any bucket:
- A test project whose subject is a declared plugin module MUST move. "It tests machinery below
the mesh" is not an exemption there, because the plugin is not being asked to run inside itself —
it is installed into a pinned image and executed there. That is exactly what
mw-plugin-testalready does for the 76 NodeTypeTestsareas in this repo. - A test project whose subject is not a module (the hosts, the storage/transport backends, the Orleans composites) is informative but not blocking.
A module is a declared one: some package's index.json names its assembly in content.module.
There are 33 such modules in this repo.
The headline
Of the 30 test projects whose subject is a declared module — 3,044 cases in 369 test files:
| cases | share | |
|---|---|---|
| Runnable install-and-execute today | 2,228 | 73% |
| Needs pre-boot service substitution (a test double) | 526 | 17% |
| Needs per-suite configuration that is the test's subject | 158 | 5% |
| Needs to act as an arbitrary identity | 93* | 3% |
| Needs a second process / silo | 21 | 1% |
| Needs an external resource beyond the image | 18 | 1% |
* cascaded count — a file is charged to its first blocker. Counted unconditionally, arbitrary-identity touches 145 module cases (4%); see facility 2.
Nearly three quarters of the set that must move, moves today — and the remaining quarter is one facility plus a long tail. Pre-boot service substitution alone is 526 of the 816 blocked cases (64%).
The non-module set (39 projects, 5,316 cases) is reported separately at the end.
🚨 The blocking unknown was false
The migration was blocked on this premise: "an in-mesh Tests layout area runs inside the mesh and
addresses nodes. It has no equivalent of reaching into the host's DI container."
It has one, it is the same one production uses, and this repo already ships tests that use it.
A Tests area receives LayoutAreaHost host. host.Hub is an IMessageHub, and
MessageHub.ServiceProvider is "the DI service provider scoped to this hub" — built from the parent
provider (MessageHubConfiguration.ConfigureServices(parent)), so it chains all the way up to the
host root provider. Everything the portal registered is resolvable.
That is not a theory. It is how in-mesh tests are written here today:
Store/Licensing/Test/PluginGateTests.cs:886—hub.ServiceProvider.GetRequiredService<IMeshService>()Store/Catalog/Test/PaidFulfilmentTests.cs:377— the same, plusGetService<AccessService>()Store/Enrollment/Test/EnrollmentTests.cs:287,Approvals/Desk/Test/ApprovalLiveTests.cs:61, …
And it is how product code resolves services —
MeshWeaver.AI.OpenAI/OpenAIChatClientAgentFactory.cs:140 does
Hub.ServiceProvider.GetRequiredService<ChatClientCredentialResolver>(). The idiom the xunit tests
use is the idiom the mesh uses.
So "the test resolves a service" was never the blocker, and a GetRequiredService<T>() site is not an
argument for staying xunit — it is a conversion cost to be sized. The real boundaries are
substitution, identity, and process count.
The DI surface of the module set is small
| module set (30 projects) | non-module set (39) | |
|---|---|---|
| cases | 3,044 | 5,316 |
| DI call sites | 242 | 990 |
| files with DI | 100 | 342 |
| distinct services resolved | 40 | ~120 |
14 of the 30 module projects have zero DI sites at all. The module histogram is dominated by three
types — IMeshService 66, AccessService 33, ChatClientCredentialResolver 31 = 130 of 242 (54%)
— and all three are ordinary product registrations already resolved in-mesh today.
Across the whole estate (both sets, 1,231 sites, 127 distinct T), scanning 2,115 product .cs files
for registrations:
- 1,012 sites (82%) resolve a service with a product registration — resolvable in-mesh unchanged.
- 109 sites (9%) resolve host intrinsics (
ILogger<T>,ILoggerFactory,IOptions<T>,IMessageHub,IConfiguration) — always present. - 110 sites (9%) resolve genuine test doubles (
CapturingChatClient,GatedReadStorageAdapter,StallingSourceQueryProvider,HangingAssemblyStore,DeliveryOrderRecorder, …).
The four facilities the module set still needs
1. Pre-boot service substitution — 526 cases (17%), and 64% of everything blocked
The suite registers a test double through ConfigureMesh before the mesh boots, then resolves it.
This is concentrated in the AI providers and is intrinsic to what they test: you cannot exercise a
model provider's retry, cache-policy or tool-wiring behaviour against a real LLM. MeshWeaver.AI.Test
has a whole sibling project, MeshWeaver.AI.Test.FakeCli, existing for this.
What is needed: a way for an installed plugin's Tests area to run against a hub with one
registration replaced — a per-area service override, applied at area activation rather than host boot.
This is the single highest-value facility to build; it alone would take the module set from 73% to
about 90%.
Examples: MeshWeaver.AI.Anthropic.Test/AnthropicRetryTest.cs,
MeshWeaver.AI.OpenAI.Test/OpenRouterPromptCachePolicyTest.cs,
MeshWeaver.AI.Test/ThreadComposerFlowTest.cs.
2. Act as an arbitrary identity — 145 module cases (4%), and this one should be DECLINED
Two mechanisms that must not be conflated, because only one is a gap:
| mechanism | sites / files (whole estate) | in-mesh today? |
|---|---|---|
AccessService.ImpersonateAsSystem() |
156 / 72 | yes — Store/Catalog/Test/PaidFulfilmentTests.cs:381 |
SetHostIdentity |
151 / 73 | no |
TestUsers.DevLogin(...) |
72 / 31 | no |
SwitchAccessContext(...) |
52 / 25 | no |
SetCircuitContext(...) |
28 / 13 | no |
System impersonation is not the gap. Counting unconditionally (a file needing any arbitrary-identity mechanism), the module set is 145 cases in 24 files — 4%; 94% of module cases manipulate identity not at all. In the non-module set it is 485 cases (9%).
🚨 And the gap should stay open — for two independent reasons.
Core forbids it on a multi-user server, in those words. AccessService.SetHostIdentity sets an
arbitrary AccessContext as a hub's standing identity, and its doc comment
(MeshWeaver.Messaging.Hub/AccessService.cs:70-80) reads:
🚨 A MULTI-USER SERVER MUST NEVER WRITE THIS. It is process-wide and last-writer-wins, so on a shared portal it would hand one user's identity to every other user's context-less read. That was exactly the cross-user identity bleed fixed here … The only writer is
SetHostIdentity, which no server code path calls — on a portal this staysnullforever and identity resolution fails closed instead of falling back to a stranger.
⚠️ It is not a test-only API, and an earlier revision of this page wrongly called it one. Exactly one
production path writes it — src/Memex.LocalMesh/DeviceSeed.cs:59, the LocalMesh single-identity device
host, which is precisely the "single-identity host" the API exists for. That makes the conclusion
stronger, not weaker: the one shipping caller is a single-user host, and core names the shared portal as
the case that must never do it. A portal Tests area is the shared-portal case.
And it is not scoped. SetHostIdentity / ClearHostIdentity mutate a process-wide, last-writer-wins
field — hostIdentity = accessContext plus circuitContext.Value, with no disposable scope
(AccessService.cs:201-229). Safe in a throwaway single-identity test host; unsafe by construction in a
shared portal, where one Tests area changes identity for every concurrent request. Contrast
ImpersonateAsSystem(), which returns an IDisposable scope — precisely why that one is already safe
in-mesh. A sibling variant, WithNoAmbientIdentity → ClearHostIdentity (7 sites), is the same gap from
the other side.
So these 145 cases either stay on xunit or get a sandboxed disposable mesh — not an in-mesh impersonation API.
3. Per-suite configuration that is the test's subject — 158 cases (5%)
Configuration the test is about: Anthropic:ApiKey, Ai:KeyProtection:MasterKey, Mcp:BaseUrl,
model catalogs. What is needed: a way to declare configuration for a Tests area's hub.
This is much smaller than it first looks. Across the mesh-base classes, 132 suites inject
configuration, but the overwhelming majority is harness plumbing that disappears in-mesh:
CompilationCacheOptions (49 — the test base's own per-class compile-cache isolation),
Graph:Storage:SourceType / BasePath / FileSystem (28 each — pointing a throwaway mesh at a temp
directory). A test running inside an already-started portal needs none of it.
4. Second process / silo — 21 cases (1%), and external resources — 18 cases (1%)
Tests that resolve services from a different process's provider
(InProcessSiloHandle.SiloHost.Services, Cluster.Client.ServiceProvider), or that need pgvector /
Azure Blob / a Cosmos emulator. Correctly out of scope for a single in-mesh area render.
Two concrete shapes a hand read surfaced that a signal model would have mislabelled:
- A local-disk fixture seeded before the hub exists.
MeshWeaver.AI.Test/MeshPluginTest.cs'sGetContentHub()builds a hosted hub withAddFileSystemContentCollection(...)over a directory the xunit constructor populates on local disk — 7 sites, and an in-mesh area has no handle on it. Not a container, not a fake service: just a directory. - An external OS child process spawned at boot.
MeshWeaver.AI.Test/ConnectStrategyTest.csconfiguresClaudeConnectOptionsto spawnMeshWeaver.AI.Test.FakeCli.dll— up to 7 sites.
5. Orleans: not a migration candidate at all
MeshWeaver.AI.Orleans.Test was read in full, all 29 sites: 18 are Orleans grain/silo mechanics as the
test subject (reentrancy, cold-load-after-restart, cross-silo cache version floors, idle-recycle
convergence) and 11 need a custom pre-start ISiloConfigurator plus a second client hub over the real
cluster. Zero of its files are pure node assertions. This project exists specifically to catch
defects a Monolith-hosted mesh structurally cannot reproduce — it should never move, and it is not debt
that it does not.
What most ConfigureMesh overrides are not
76% of mesh test classes (480 of 629) override ConfigureMesh, which reads as a wall. It is not:
| what the override does | classes | blocker? |
|---|---|---|
| adds assemblies / modules / node types only | 269 | no — in-mesh the module is already installed |
| injects configuration or options | 132 | mostly no — harness plumbing (see above) |
| other / trivial | 50 | no |
| substitutes a product service with a test double | 27 | yes |
| registers extra services (no double) | 2 | probably not |
The assertion vocabulary is portable — the other feared cost
There are 11,750 .Should() call sites across the mesh-base classes, and no in-mesh test uses one
today (all 76 existing Tests areas throw exceptions by hand). That looks like an 11,750-site rewrite.
It is not.
.Should() here is not FluentAssertions. Core replaced it with an in-house library,
MeshWeaver.Reactive.Assertions (core src/, 2,116 lines), which:
- has no xunit dependency at all — its only
PackageReferenceisSystem.Reactive; - fails by throwing
AssertionException— precisely what the in-mesh runner already catches and renders as a red row; - lives under core
src/, socompile-check.py's reference set (<core>/src/*/bin/**/*.dll) already picks it up.
The whole assertion vocabulary becomes available in-mesh by shipping one assembly in the portal image. No test rewrite. This is the highest-leverage single item in the migration, and it is not a DI question.
xunit features an in-mesh runner must replicate — restricted to the module set
| feature | module set (30) | non-module set (39) | verdict for the module set |
|---|---|---|---|
[Theory] |
129 | 233 | expressible — the runner takes a (string Name, Action Body)[]; one tuple per row |
[InlineData] |
597 | 1,038 | same — mechanical |
[MemberData] |
3 | 21 | negligible |
[ClassData] |
0 | 0 | — |
IClassFixture |
1 | 5 | negligible |
ICollectionFixture |
0 | 4 | — |
[Collection] |
19 | 166 | free — serialization of shared external resources; the in-mesh runner is serial by construction |
IAsyncLifetime |
1 | 17 | negligible |
| test-case ordering | 0 | 1 | — |
Nothing here blocks the programme. The one feature with real volume is [Theory]/[InlineData]
(597 rows), and a tuple-array runner expresses those mechanically. Every fixture-and-lifecycle feature
in the whole estate belongs to the non-module storage suites: all ICollectionFixture uses are
Postgres/Snowflake/Cosmos, and 17 of 18 IAsyncLifetime classes are the same.
Per-project: the module set
| project | cases | runnable today | DI files | DI sites |
|---|---|---|---|---|
| MeshWeaver.AI.Test | 1,633 | 1,133 (69%) | 65 | 171 |
| MeshWeaver.Markdown.Collaboration.Test | 400 | 364 (91%) | 1 | 7 |
| MeshWeaver.Observability.Test | 198 | 182 (91%) | 5 | 11 |
| MeshWeaver.Markdown.Export.Test | 197 | 134 (68%) | 7 | 11 |
| MeshWeaver.Graph.Views.Test | 142 | 128 (90%) | 4 | 6 |
| MeshWeaver.Mail.MicrosoftGraph.Test | 67 | 49 (73%) | 2 | 2 |
| MeshWeaver.AI.OpenAI.Test | 63 | 10 (15%) | 1 | 1 |
| MeshWeaver.Mcp.Test | 59 | 17 (28%) | 3 | 8 |
| MeshWeaver.Blazor.EntityViews.Test | 52 | 49 (94%) | 2 | 4 |
| MeshWeaver.Courses.Test | 32 | 26 (81%) | 0 | 0 |
| MeshWeaver.Publish.Test | 29 | 29 (100%) | 0 | 0 |
| MeshWeaver.AI.Anthropic.Test | 26 | 0 (0%) | 2 | 4 |
| MeshWeaver.Import.Test | 21 | 17 (80%) | 4 | 5 |
| MeshWeaver.SelfUpdate.Aks.Test | 21 | 21 (100%) | 1 | 1 |
| MeshWeaver.AI.AppleIntelligence.Test | 19 | 19 (100%) | 0 | 0 |
| MeshWeaver.AI.WebSearch.Test | 16 | 0 (0%) | 0 | 0 |
| MeshWeaver.ContentCollections.Indexing.PostgreSql.Test | 11 | 0 (0%) | 1 | 9 |
| (12 more, ≤8 cases each) | 65 | 60 (92%) | 2 | 2 |
MeshWeaver.AI.Test is the programme: 1,633 of 3,044 cases (54%) and 171 of 242 DI sites (71%).
Everything else is small, and 12 of the smallest projects are essentially free.
The non-module set — informative, not blocking
39 projects, 5,316 cases, 990 DI sites. 714 of the estate's 1,231 DI sites were classified by hand, site by site, in three independent passes:
| scope | sites | B1 mesh-addr | B2 substrate | B3 facility | B4 incidental | B5 unclear |
|---|---|---|---|---|---|---|
| storage / transport (11 projects) | 216 | 6 | 160 | 47 | 3 | 0 |
Hosting.Monolith.Test |
299 | 94 | 137 | 50 | 18 | 0 |
| long tail (22 projects) | 199 | 90 | 68 | 24 | 16 | 1 |
| judged total | 714 | 190 | 365 | 121 | 37 | 1 |
Notable per-project results from the hand read:
- The storage/transport projects are 74% substrate. ~85% of
Hosting.PostgreSql.Test's files need a live Postgres regardless of the in-mesh question. Correctly xunit; they should stay. MeshWeaver.Threading.Testis substrate-bound — confirmed by hand, 8 of 18 sites B2 and only 2 ordinary mesh-content assertions.IoPoolRegistry.DrainAll()pins host teardown sequencing;OrphanedStartingExecutionRecoveryTestwrites throughIStorageAdapterto reproduce a "hub died mid-transaction" shape no legitimate write API can produce.Memex.Portal.Gui.Testis 9/9 substrate and touches no mesh at all — every site builds a standaloneServiceCollectionto test ASP.NET host auth wiring. "Could this run in-mesh" does not apply.MeshWeaver.Mcp.Testlooked like a transport project and is actually blocked by the identity gap, not by MCP: 5 of 8 sites are arbitrary-identity or fake-GitHub.Autocomplete.Testis the most convertible mid-size project (58% B1, one substrate site).- Only one site in 714 was genuinely unclear:
Blazor.Views.Test/SearchResultStormTest.cs:87— ordinary mesh writes, but what is asserted is a Blazor re-render count, which has no in-mesh equivalent.
🚨 A reconciliation the hand read forced, and what survived it
The hand read's central methodological criticism is correct and worth stating plainly: AccessService
cannot be bucketed by type, and a type-level rule is optimistic wherever it appears. Audited directly,
23 of the 33 AccessService sites in the module set are arbitrary-identity (B3), not the benign
"already resolved in-mesh" reading — so the attribution between facilities in the cascaded table above
under-counts identity (93 cascaded vs 145 unconditional).
The 73% headline nevertheless survives. Re-running the module-set model with the identity test widened
to everything the hand read found (ClearHostIdentity, access.SetContext(...), WithNoAmbientIdentity
alongside SetHostIdentity / DevLogin / SwitchAccessContext / SetCircuitContext) reclassifies
zero additional files: every module file carrying an arbitrary-identity site was already blocked by an
earlier signal in the cascade. Runnable stays 2,228–2,236 of 3,044 = 73%.
So: the criticism changes which facility a blocked case is charged to, and it changes the non-module picture (Threading.Test especially). It does not change what must be built, or how much moves.
What is mechanical and what is judged
Mechanical (a script re-derives it exactly): the module split from content.module across 33
package index.json files; DI site and file counts; the service histograms; product-registration
presence across 2,115 product files; class and case counts by parser with brace matching; the
xunit-feature tables; ConfigureMesh classification by what the body calls; .Should() counts;
MeshWeaver.Reactive.Assertions having no xunit reference.
Judged: the bucket assignment. The module-set disposition is a rule-based model over structural
signals applied per test file (external resource → facility; second-process token → facility; test
double → facility; impersonation token → facility; subject configuration → facility; else runnable),
plus three independent per-site human classifications covering 714 of the estate's 1,231 sites. It is
not a per-site human reading of all 242 module sites — but the one axis where that was most likely to
matter (AccessService) was audited directly afterwards, and is reported above.
Both directions of error should be stated:
- Pessimistic: the model marks a whole file blocked if any signal fires anywhere in it, so a file
with one impersonating case and nine ordinary ones is counted wholly blocked. The true runnable share
is therefore higher than 67%, not lower. An earlier pass read
MeshWeaver.Publish.Testat 0%; the signal was a bareBuildServiceProvider()in a pure parser test, and tightening it moved that project to 100% and the whole set from 67% to 73%. Other files will be similarly over-marked. - Optimistic: it assumes harness-only configuration (
CompilationCacheOptions,Graph:Storage) simply disappears when a test runs inside an already-started portal. If an installed plugin's Tests area turns out to need compile-cache isolation, that assumption moves cases into facility 3.
⚠️ Independent raters disagree by 2–5× on the soft boundaries
Five independent full-corpus passes over the same 209 AI-project sites produced bucket-1 counts ranging
97–134, bucket-3 14–75, and bucket-4 1–44. That spread is itself a finding: the
mesh-addressable-vs-incidental boundary is definitional (does seeding a precondition node via
IMeshService.CreateNode(...) before the real assertion count as central or incidental?), and the
"is SetHostIdentity safe from inside a shared host" question is a product-policy decision, not a
DI classification. Any single confident split of those 209 sites would be false precision. The
73% headline does not depend on them, because it is a file-level runnable/blocked judgement — a file
is blocked whichever facility it is charged to.
A grep trap for anyone re-running this
IChatClient.GetService<T>() is Microsoft.Extensions.AI's pipeline-unwrap accessor and shares its name
with IServiceProvider.GetService<T>(). Counted precisely, the 1,231 reported sites contain 8 false
positives (5 comment/doc text, 3 IChatClient.GetService —
AI.Test/AgentToolWiringIntegrationTest.cs:199,200, AI.AzureFoundry.Test/AzureFoundryPromptCacheReportingTest.cs:89),
so the real figure is 1,223 sites — a 0.6% error that changes no conclusion but will bite a naive re-run.
Genuinely unclear — not forced into a bucket. Two things:
- Where a converted test WRITES. 183 of the 629 mesh-base classes (29%) write nodes, and 25 use
SeedTopLevelto create top-level partition roots as System —PartitionWriteGuardValidatorrejects a non-System caller doing that, and in a live portal it is a global mutation. Whether the answer is a scratch path per area or a disposable sandboxed mesh is not determined by any measurement here. AccessServicecannot be classified by type at all. The identicalGetRequiredService<AccessService>()line is mesh-addressable, substrate, or blocked depending only on the method chained onto it three lines later (.ViewerZoneId()vs.CircuitContextvs.ImpersonateAsSystem()). Any type-level rule — including the one used here — misclassifies some of its 247 sites. The cascaded identity figure (93) and the unconditional one (145) bracket it.
Reconciling with the briefing's figures
The briefing quoted 266 MonolithMeshTestBase inheritors, 169 DI files and ~3,947 cases. Measured at
57d84019: 609 inheritors by plain grep (604 by parser), 442 DI files / 1,231 sites, and
2,848 cases in the 629 mesh-base classes (8,360 across all test projects). The six projects the
briefing named do sum to 139 files here, so its per-project numbers are close — but its total omitted
the largest holder, MeshWeaver.Hosting.Monolith.Test (123 files, 300 sites), plus Security, Auth,
PluginCatalog and GitSync. The sibling core checkout holds a second estate of 453 classes /
2,102 cases; neither repo alone nor the pair sums to 3,947.
What to do with this
- Ship
MeshWeaver.Reactive.Assertionsin the portal image. One assembly, no xunit, unlocks the whole assertion vocabulary. Independent of everything else — do it first. - Build per-area service substitution. 526 cases (17% of the module set, and 64% of everything blocked) turn on it; it is the only facility with real volume, and it takes the module set from 73% to ~90%.
- Convert the free projects now. 12 module projects at ≤8 cases are ~92% runnable; four more
(
Markdown.Collaboration,Observability,Graph.Views,Blazor.EntityViews) are 90%+ and carry 792 cases between them. - Answer the disposable-write question before converting anything that calls
SeedTopLevel. - Decline the identity facility. 145 module cases (4%) want an arbitrary identity.
SetHostIdentityis documented in core as called by no server code path — a test-only door. Re-opening it inside a live portal would contradict the platform's own access rules. These stay on xunit, or get a sandbox. - Leave the non-module storage and transport suites on xunit. That is not debt.