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 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:

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:

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 yesStore/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 stays null forever 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 fieldhostIdentity = 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, WithNoAmbientIdentityClearHostIdentity (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:

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:

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:

🚨 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:

⚠️ 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.GetServiceAI.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:

  1. Where a converted test WRITES. 183 of the 629 mesh-base classes (29%) write nodes, and 25 use SeedTopLevel to create top-level partition roots as System — PartitionWriteGuardValidator rejects 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.
  2. AccessService cannot be classified by type at all. The identical GetRequiredService<AccessService>() line is mesh-addressable, substrate, or blocked depending only on the method chained onto it three lines later (.ViewerZoneId() vs .CircuitContext vs .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

  1. Ship MeshWeaver.Reactive.Assertions in the portal image. One assembly, no xunit, unlocks the whole assertion vocabulary. Independent of everything else — do it first.
  2. 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%.
  3. 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.
  4. Answer the disposable-write question before converting anything that calls SeedTopLevel.
  5. Decline the identity facility. 145 module cases (4%) want an arbitrary identity. SetHostIdentity is 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.
  6. Leave the non-module storage and transport suites on xunit. That is not debt.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.