From 0c7561e8a1ea2730d67c543b68a23dfa46e121e8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:58:27 +0100 Subject: [PATCH 1/3] fix(mocks): keep editors in sync with publicized project references (#6836) Experimental internals access swaps the compiler's view of a selected reference with a publicized copy. When that reference comes from a ProjectReference, Roslyn-workspace tooling (MSBuildWorkspace, the C# language server, OmniSharp) binds to the referenced project's own compilation instead, which has no publicized internals: the build succeeds while the editor reports CS0122 on every internal type, plus a follow-on CS1503 where the mock is passed on. Detach those project references in design-time builds only, by setting ReferenceOutputAssembly=false on the ProjectReference items backing the references this run publicized. Nothing is compiled or copied in a design-time build, so the publicized copy is left as the only reference for that assembly and the editor sees what Csc sees. Real builds are untouched: the project reference still builds, copies local and lands in deps.json. A failed publicize produces no items, so the project reference stays intact. Opt out with TUnitMocksInternalsAccessDetachDesignTimeProjectReferences=false. Verified with an MSBuildWorkspace probe over the repository's own internals-access test project: 13 errors before, 0 after. Transitive project references were already correct and are unaffected. --- docs/docs/writing-tests/mocking/advanced.md | 28 +++ .../TUnit.Mocks.InternalsAccess.targets | 25 ++ .../DesignTimeProjectReferenceTests.cs | 215 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs diff --git a/docs/docs/writing-tests/mocking/advanced.md b/docs/docs/writing-tests/mocking/advanced.md index 964de2898e..df1c8e7e9a 100644 --- a/docs/docs/writing-tests/mocking/advanced.md +++ b/docs/docs/writing-tests/mocking/advanced.md @@ -377,6 +377,34 @@ ships and loads; an `IgnoresAccessChecksTo` attribute (honored by the .NET runti compiled IL valid against it at execution time. This is the established "publicizer" pattern used by several long-lived OSS tools, wired into the TUnit.Mocks package. +### Editors and design-time builds + +The publicized copy reaches the compiler through the reference list, so editors and IDE tooling +see the same internals the build does. + +One case needs a nudge: when a listed assembly comes from a `ProjectReference`, tooling that +loads projects through Roslyn's MSBuild workspace (the C# language server, OmniSharp, anything on +`MSBuildWorkspace`) binds to the referenced *project's* compilation, which has no publicized +internals — the build succeeds while the editor underlines every internal type with `CS0122` +([#6836](https://github.com/thomhurst/TUnit/issues/6836)). TUnit therefore +detaches that project reference in **design-time builds only**, leaving the publicized copy as +the reference for that assembly. Real builds are untouched: the project still builds, copies +local, and lands in `deps.json`. + +The trade-off is that the referenced project shows up in the editor as a compiled assembly: "go +to definition" lands on metadata rather than its source, and edits to it reach the test project +after a rebuild. To keep the live project reference instead — and the false `CS0122` reports that +come with it — set: + +```xml + + false + +``` + +For a project you own, `[assembly: InternalsVisibleTo]` remains the simpler answer; internals +access exists for assemblies you cannot change. + ### Caveats - **Experimental.** `IgnoresAccessChecksToAttribute` is honored by the runtime but is not a diff --git a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets index 0b16aa073a..4403c5d2a2 100644 --- a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets +++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets @@ -44,6 +44,11 @@ true + + true + + + + <_TUnitMocksPublicizedProjectFile Include="@(_TUnitMocksPublicizedReference->'%(MSBuildSourceProjectFile)')" + Condition="'%(_TUnitMocksPublicizedReference.MSBuildSourceProjectFile)' != ''" /> + + + + diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs new file mode 100644 index 0000000000..eb32b693d0 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs @@ -0,0 +1,215 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Task = System.Threading.Tasks.Task; + +namespace TUnit.Mocks.InternalsAccess.Tests; + +// #6836: a publicized reference that came from a ProjectReference stays a live project +// reference for Roslyn-workspace tooling (MSBuildWorkspace, the C# language server, +// OmniSharp). Those hosts reference the referenced project's own compilation, which has no +// publicized internals, so the editor reports a false CS0122 on code the compiler accepts. +// The targets detach that project reference in design-time builds only; a real build must keep +// it, or copy-local and deps.json would lose the assembly. +// +// The scenario is generated outside the repository so the probe is a plain SDK project: no +// repo-wide props, no shared obj directory, nothing to race with a parallel build. + +public class DesignTimeProjectReferenceTests +{ + [Test] + public async Task Design_Time_Build_Detaches_The_Publicized_Project_Reference() + { + var scenario = await Scenario.CreateAsync(); + + var reference = await scenario.QueryProjectReferenceAsync(designTimeBuild: true); + + await Assert.That(reference).IsEqualTo("false"); + } + + [Test] + public async Task Real_Build_Keeps_The_Publicized_Project_Reference() + { + var scenario = await Scenario.CreateAsync(); + + var reference = await scenario.QueryProjectReferenceAsync(designTimeBuild: false); + + // Untouched: no ReferenceOutputAssembly metadata was written at all. + await Assert.That(reference).IsEqualTo(""); + } + + [Test] + public async Task Detaching_Can_Be_Opted_Out_Of() + { + var scenario = await Scenario.CreateAsync(); + + var reference = await scenario.QueryProjectReferenceAsync( + designTimeBuild: true, + "-p:TUnitMocksInternalsAccessDetachDesignTimeProjectReferences=false"); + + await Assert.That(reference).IsEqualTo(""); + } + + private sealed class Scenario + { + private const string LibraryAssemblyName = "DesignTimeSdkLib"; + + private Scenario(string probeProject) => ProbeProject = probeProject; + + private string ProbeProject { get; } + + public static async Task CreateAsync() + { + var root = Path.Combine(Path.GetTempPath(), "tunit-mocks-ia-designtime", Guid.NewGuid().ToString("N")); + var library = Path.Combine(root, "lib"); + var probe = Path.Combine(root, "probe"); + Directory.CreateDirectory(library); + Directory.CreateDirectory(probe); + + // Stop MSBuild walking out of the temp directory for props/targets it should not find. + File.WriteAllText(Path.Combine(root, "Directory.Build.props"), ""); + File.WriteAllText(Path.Combine(root, "Directory.Build.targets"), ""); + + File.WriteAllText(Path.Combine(library, "lib.csproj"), + $""" + + + {TargetFramework} + {LibraryAssemblyName} + + + """); + + File.WriteAllText(Path.Combine(library, "Api.cs"), + $$""" + namespace {{LibraryAssemblyName}}; + + internal interface IQuotaPolicy + { + bool Allow(string clientId); + } + """); + + File.WriteAllText(Path.Combine(probe, "probe.csproj"), + $""" + + + {TargetFramework} + true + {TasksAssembly} + + + + + + + + """); + + // Naming the internal type is what the publicized reference buys; if the swap stops + // working this file no longer compiles. + File.WriteAllText(Path.Combine(probe, "Use.cs"), + $$""" + using {{LibraryAssemblyName}}; + + internal static class Use + { + internal static bool Allow(IQuotaPolicy policy) => policy.Allow("acme"); + } + """); + + var probeProject = Path.Combine(probe, "probe.csproj"); + await RunAsync("build", probeProject); + return new Scenario(probeProject); + } + + /// + /// Runs the compile pipeline without invoking the compiler and reports the + /// ReferenceOutputAssembly metadata the project reference carries afterwards. + /// + public async Task QueryProjectReferenceAsync(bool designTimeBuild, params string[] extraArguments) + { + List arguments = + [ + "-t:Compile", + "-p:SkipCompilerExecution=true", + "-p:ProvideCommandLineArgs=true", + ]; + + if (designTimeBuild) + { + // What an IDE passes: nothing is compiled or copied, so the reference swap is + // free to reshape the reference set the workspace reads back. + arguments.Add("-p:DesignTimeBuild=true"); + arguments.Add("-p:BuildProjectReferences=false"); + } + + arguments.AddRange(extraArguments); + arguments.Add("-getItem:ProjectReference"); + + var output = await RunAsync("msbuild", ProbeProject, [.. arguments]); + + using var document = JsonDocument.Parse(output[output.IndexOf('{')..]); + var items = document.RootElement.GetProperty("Items").GetProperty("ProjectReference"); + var item = items.EnumerateArray().Single(); + return item.TryGetProperty("ReferenceOutputAssembly", out var metadata) ? metadata.GetString() ?? "" : ""; + } + + private static async Task RunAsync(string verb, string project, params string[] arguments) + { + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + // A reused MSBuild node keeps the task assembly — this test project's own build + // output — loaded and locked for the next build in this repository. + ArgumentList = { verb, project, "-nologo", "-nr:false" }, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo)!; + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + var output = await standardOutput; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + new StringBuilder() + .AppendLine($"dotnet {verb} {project} {string.Join(' ', arguments)} exited with {process.ExitCode}.") + .AppendLine(output) + .AppendLine(await standardError) + .ToString()); + } + + return output; + } + + private static string TargetFramework => "net10.0"; + + private static string TasksAssembly => + Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.InternalsAccess.Tasks.dll"); + + private static string TargetsFile => FindRepositoryFile( + Path.Combine("src", "TUnit.Mocks", "TUnit.Mocks.InternalsAccess.targets")); + + private static string FindRepositoryFile(string relativePath) + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new FileNotFoundException($"'{relativePath}' was not found above '{AppContext.BaseDirectory}'."); + } + } +} From c2bcc3746bd695fec5bddf00896d0533a077c030 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:10:22 +0100 Subject: [PATCH 2/3] test(mocks): clean up design-time scenarios and assert the compiler reference Review follow-ups on #6836: - Scenario is now IAsyncDisposable and deletes its generated project pair, so repeated local and CI runs stop accumulating temp builds. - New test asserts the detach does not cost the reference: the design-time compiler command line still carries exactly one reference for the publicized assembly, and it is the publicized copy. The design-time argument set now also passes BuildingInsideVisualStudio and BuildingProject, as real design-time hosts do. Those drive _ComputeNonExistentFileProperty, without which CoreCompile is skipped as up to date and reports no command line at all. --- .../DesignTimeProjectReferenceTests.cs | 89 +++++++++++++++---- 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs index eb32b693d0..056ff40abc 100644 --- a/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs @@ -20,17 +20,30 @@ public class DesignTimeProjectReferenceTests [Test] public async Task Design_Time_Build_Detaches_The_Publicized_Project_Reference() { - var scenario = await Scenario.CreateAsync(); + await using var scenario = await Scenario.CreateAsync(); var reference = await scenario.QueryProjectReferenceAsync(designTimeBuild: true); await Assert.That(reference).IsEqualTo("false"); } + [Test] + public async Task Detached_Project_Reference_Still_Compiles_Against_The_Publicized_Copy() + { + await using var scenario = await Scenario.CreateAsync(); + + // The detach must not cost the reference: what the editor reads back is the compiler + // command line, and the publicized copy has to be the assembly on it. + var references = await scenario.QueryCompilerReferencesAsync(designTimeBuild: true); + + await Assert.That(references).HasSingleItem(); + await Assert.That(references[0]).Contains(Path.Combine("tunit-mocks-internals")); + } + [Test] public async Task Real_Build_Keeps_The_Publicized_Project_Reference() { - var scenario = await Scenario.CreateAsync(); + await using var scenario = await Scenario.CreateAsync(); var reference = await scenario.QueryProjectReferenceAsync(designTimeBuild: false); @@ -41,7 +54,7 @@ public async Task Real_Build_Keeps_The_Publicized_Project_Reference() [Test] public async Task Detaching_Can_Be_Opted_Out_Of() { - var scenario = await Scenario.CreateAsync(); + await using var scenario = await Scenario.CreateAsync(); var reference = await scenario.QueryProjectReferenceAsync( designTimeBuild: true, @@ -50,11 +63,17 @@ public async Task Detaching_Can_Be_Opted_Out_Of() await Assert.That(reference).IsEqualTo(""); } - private sealed class Scenario + private sealed class Scenario : IAsyncDisposable { private const string LibraryAssemblyName = "DesignTimeSdkLib"; - private Scenario(string probeProject) => ProbeProject = probeProject; + private Scenario(string root, string probeProject) + { + Root = root; + ProbeProject = probeProject; + } + + private string Root { get; } private string ProbeProject { get; } @@ -120,7 +139,14 @@ internal static class Use var probeProject = Path.Combine(probe, "probe.csproj"); await RunAsync("build", probeProject); - return new Scenario(probeProject); + return new Scenario(root, probeProject); + } + + public ValueTask DisposeAsync() + { + // -nr:false leaves no MSBuild node behind, so nothing still holds the outputs. + Directory.Delete(Root, recursive: true); + return ValueTask.CompletedTask; } /// @@ -128,6 +154,41 @@ internal static class Use /// ReferenceOutputAssembly metadata the project reference carries afterwards. /// public async Task QueryProjectReferenceAsync(bool designTimeBuild, params string[] extraArguments) + { + var arguments = ArgumentsFor(designTimeBuild); + + arguments.AddRange(extraArguments); + + var items = await QueryItemsAsync("ProjectReference", [.. arguments]); + var item = items.EnumerateArray().Single(); + return item.TryGetProperty("ReferenceOutputAssembly", out var metadata) ? metadata.GetString() ?? "" : ""; + } + + /// + /// The /reference: arguments the compiler would be invoked with for the publicized + /// assembly — what a workspace host reads back as the project's metadata references. + /// + public async Task QueryCompilerReferencesAsync(bool designTimeBuild) + { + var items = await QueryItemsAsync("CscCommandLineArgs", [.. ArgumentsFor(designTimeBuild)]); + + return items.EnumerateArray() + .Select(item => item.GetProperty("Identity").GetString() ?? "") + .Where(argument => argument.StartsWith("/reference:", StringComparison.Ordinal) + && argument.Contains(LibraryAssemblyName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + private async Task QueryItemsAsync(string itemName, string[] arguments) + { + var output = await RunAsync("msbuild", ProbeProject, [.. arguments, "-getItem:" + itemName]); + + // -getItem prints JSON, but a warning can still precede it. + var document = JsonDocument.Parse(output[output.IndexOf('{')..]); + return document.RootElement.GetProperty("Items").GetProperty(itemName); + } + + private static List ArgumentsFor(bool designTimeBuild) { List arguments = [ @@ -139,20 +200,16 @@ public async Task QueryProjectReferenceAsync(bool designTimeBuild, param if (designTimeBuild) { // What an IDE passes: nothing is compiled or copied, so the reference swap is - // free to reshape the reference set the workspace reads back. + // free to reshape the reference set the workspace reads back. The last two also + // drive _ComputeNonExistentFileProperty, which is what makes CoreCompile run + // (and so report its command line) even when the outputs are up to date. arguments.Add("-p:DesignTimeBuild=true"); arguments.Add("-p:BuildProjectReferences=false"); + arguments.Add("-p:BuildingInsideVisualStudio=true"); + arguments.Add("-p:BuildingProject=false"); } - arguments.AddRange(extraArguments); - arguments.Add("-getItem:ProjectReference"); - - var output = await RunAsync("msbuild", ProbeProject, [.. arguments]); - - using var document = JsonDocument.Parse(output[output.IndexOf('{')..]); - var items = document.RootElement.GetProperty("Items").GetProperty("ProjectReference"); - var item = items.EnumerateArray().Single(); - return item.TryGetProperty("ReferenceOutputAssembly", out var metadata) ? metadata.GetString() ?? "" : ""; + return arguments; } private static async Task RunAsync(string verb, string project, params string[] arguments) From c925213556910abaaf3f66b282476edb5c8b2b4c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:12:57 +0100 Subject: [PATCH 3/3] test(mocks): harden the generated design-time scenario - XML-escape the checkout paths interpolated into the generated project; a path may legally contain characters that are markup. - Delete the scenario root when its build fails, so a failed setup leaves nothing behind either. - Run the spawned builds with MSBUILDUSESERVER=0 and node reuse off. The MSBuild server outlives the process and keeps the publicizer task assembly loaded, which locks this test project own build output against the next build in the repository (MSB3027). --- .../DesignTimeProjectReferenceTests.cs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs index 056ff40abc..66c0efbc43 100644 --- a/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/DesignTimeProjectReferenceTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Security; using System.Text; using System.Text.Json; using Task = System.Threading.Tasks.Task; @@ -115,13 +116,13 @@ internal interface IQuotaPolicy {TargetFramework} true - {TasksAssembly} + {Xml(TasksAssembly)} - + """); @@ -138,10 +139,25 @@ internal static class Use """); var probeProject = Path.Combine(probe, "probe.csproj"); - await RunAsync("build", probeProject); + + try + { + await RunAsync("build", probeProject); + } + catch + { + Directory.Delete(root, recursive: true); + throw; + } + return new Scenario(root, probeProject); } + /// + /// A checkout path may legally contain characters that are markup in a project file. + /// + private static string Xml(string path) => SecurityElement.Escape(path); + public ValueTask DisposeAsync() { // -nr:false leaves no MSBuild node behind, so nothing still holds the outputs. @@ -219,7 +235,13 @@ private static async Task RunAsync(string verb, string project, params s RedirectStandardOutput = true, RedirectStandardError = true, // A reused MSBuild node keeps the task assembly — this test project's own build - // output — loaded and locked for the next build in this repository. + // output — loaded and locked for the next build in this repository. The MSBuild + // server outlives the process entirely and holds the same lock, so both are off. + Environment = + { + ["MSBUILDUSESERVER"] = "0", + ["MSBUILDDISABLENODEREUSE"] = "1", + }, ArgumentList = { verb, project, "-nologo", "-nr:false" }, };