diff --git a/Directory.Packages.props b/Directory.Packages.props index 71ed51660d..0c91c1c747 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,7 @@ + diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx index b62cc54ae3..d5cd80e607 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -27,6 +27,7 @@ + @@ -76,6 +77,8 @@ + + diff --git a/TUnit.slnx b/TUnit.slnx index 17bf9a5b92..ac3754c10a 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -27,6 +27,7 @@ + @@ -77,6 +78,8 @@ + + diff --git a/docs/docs/writing-tests/mocking/advanced.md b/docs/docs/writing-tests/mocking/advanced.md index 261cb495d4..3f3d11b58e 100644 --- a/docs/docs/writing-tests/mocking/advanced.md +++ b/docs/docs/writing-tests/mocking/advanced.md @@ -238,3 +238,57 @@ mock.Invocations.Count; // 0 (history cleared) ``` The `SetupAllProperties()` flag is preserved across resets. + +## Internals Access (experimental) + +Some SDKs route behavior through types that are `internal` to their own assembly — the classic +example is `Microsoft.Azure.Functions.Worker`, whose `IInvocationFeatures.Get()` is called +inside the SDK with `T = IFunctionBindingsFeature`, a type your test assembly cannot even name. +Runtime-proxy libraries can auto-substitute such types (when the SDK grants `InternalsVisibleTo` +to Castle's proxy assembly), but they can never let you *configure* one. + +TUnit.Mocks can, behind an experimental opt-in: + +```xml + + true + + + + + + +``` + +Internal types of the listed assemblies then behave like public ones in your test project — +nameable, source-generator mocked, with fully typed setups, matchers, and verification. No +`InternalsVisibleTo` is required from the target assembly: + +```csharp +var bindings = IFunctionBindingsFeature.Mock(); // internal to the SDK +bindings.InvocationResult.Returns(myResult); + +features.Get().Returns(bindings.Object); +features.Get().WasCalled(Times.Once); +``` + +### How it works + +At build time, each listed reference is swapped — for the compiler only — with a copy whose +internals are rewritten to public, preserving the assembly identity. The original assembly still +ships and loads; an `IgnoresAccessChecksTo` attribute (honored by the .NET runtime) makes the +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. + +### Caveats + +- **Experimental.** `IgnoresAccessChecksToAttribute` is honored by the runtime but is not a + documented public contract. +- Not supported on .NET Framework test targets (the runtime there does not honor the attribute); + a build warning is emitted and the pipeline stays inert. +- Works under trimmed publishes; Native AOT is not yet verified. +- If another package already injects an `IgnoresAccessChecksToAttribute` definition into your + compilation (e.g. IgnoresAccessChecksToGenerator), suppress TUnit's copy with + `false`. +- Internal APIs are internal for a reason: they can change in any release of the target package. + Prefer public seams when they exist. diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs new file mode 100644 index 0000000000..93fc08ce91 --- /dev/null +++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs @@ -0,0 +1,630 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Mono.Cecil; + +namespace TUnit.Mocks.InternalsAccess.Tasks; + +/// +/// Experimental (#6514 "Tier 2"). For each requested assembly reference, produces a +/// compile-time-only copy whose internal types and members are rewritten to public, and emits a +/// source file applying IgnoresAccessChecksToAttribute so the runtime skips accessibility +/// checks from the test assembly to those assemblies. +/// +/// The publicized copy keeps the original assembly identity (name, version, public key), so IL +/// compiled against it binds to the ORIGINAL assembly at runtime — the rewritten copy never +/// ships and is never loaded. Compiler sees public; runtime sees the real thing and is told not +/// to check. This is the established "publicizer" pattern (Krafs.Publicizer, +/// IgnoresAccessChecksToGenerator), applied here so the TUnit.Mocks source generator can treat +/// another assembly's internal interfaces as first-class mockable types. +/// +public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task +{ + /// + /// The compiler's resolved references (@(ReferencePathWithRefAssemblies)) — each ItemSpec is + /// exactly the file the compiler would consume, reference assemblies included. + /// + [Required] + public ITaskItem[] ReferencePaths { get; set; } = []; + + /// Simple assembly names to publicize (@(TUnitMocksInternalsAccess)). + [Required] + public ITaskItem[] AssembliesToPublicize { get; set; } = []; + + /// + /// Runtime/copy-local assets (implementation assemblies), used to locate the implementation + /// when the compiler reference is itself a metadata-only reference assembly with no + /// %(OriginalPath) — e.g. a package whose compile asset comes straight from ref/<tfm>. + /// + public ITaskItem[] RuntimeAssemblies { get; set; } = []; + + /// Directory for the rewritten compile-time copies. + [Required] + public string OutputDirectory { get; set; } = ""; + + /// Path of the generated IgnoresAccessChecksTo source file. + [Required] + public string GeneratedSourceFile { get; set; } = ""; + + /// + /// Whether the generated source also defines IgnoresAccessChecksToAttribute. Turn off when + /// another package (IgnoresAccessChecksToGenerator, Fody, ...) already injects the same type + /// into the compilation, which would otherwise be a duplicate-type compile error. + /// + public bool EmitAttributeDefinition { get; set; } = true; + + /// + /// Publicized references. ItemSpec = rewritten copy; %(Original) = the reference item it + /// replaces (the selected match). + /// + [Output] + public ITaskItem[] PublicizedReferences { get; set; } = []; + + /// + /// Every resolved reference superseded by a publicized copy — the selected match plus any + /// same-simple-name duplicates. The targets file Removes all of these so an ambiguous match + /// degrades to "first wins, cleanly" instead of the compiler seeing the same assembly + /// identity twice (CS1703). + /// + [Output] + public ITaskItem[] SupersededReferences { get; set; } = []; + + public override bool Execute() + { + Directory.CreateDirectory(OutputDirectory); + + var outputs = new List(); + var superseded = new List(); + var publicizedNames = new List(); + + // The same simple name requested twice (duplicate items, multi-import) must not produce + // two publicized items — the targets would hand Csc the same rewritten assembly twice. + var requestedNames = AssembliesToPublicize + .Select(i => i.ItemSpec) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var name in requestedNames) + { + var matches = FindReferenceMatches(name); + + if (matches.Count == 0) + { + Log.LogError( + subcategory: null, errorCode: "TUMIA001", helpKeyword: null, + file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0, + message: $"TUnitMocksInternalsAccess: no resolved reference named '{name}' was found. " + + "The value must be the simple assembly name of a direct or transitive reference."); + continue; + } + + // EVERY distinct assembly identity behind the requested name is publicized — a + // same-file-name pair with different identities (version or public key, legal only + // under extern aliases) gets one publicized copy each, since the simple-name request + // has no way to single one out. Same-identity duplicates collapse onto one copy. + var (identityGroups, unreadableMatches) = GroupMatchesByIdentity(matches); + + if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) + { + var unreadableNote = unreadableMatches.Count > 0 + ? " References whose assembly identity cannot be read are left in place: " + + $"{string.Join(", ", unreadableMatches.Select(m => m.ItemSpec))}." + : ""; + Log.LogWarning( + subcategory: null, warningCode: "TUMIA004", helpKeyword: null, + file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0, + message: $"TUnitMocksInternalsAccess: multiple resolved references match '{name}': " + + $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Each distinct " + + "assembly identity is publicized separately; same-identity duplicates " + + "collapse onto one copy (extern aliases and EmbedInteropTypes " + + $"preserved).{unreadableNote}"); + } + + foreach (var identityGroup in identityGroups) + { + PublicizeGroup(name, identityGroup, outputs, superseded, publicizedNames); + } + } + + if (!Log.HasLoggedErrors) + { + WriteIgnoresAccessChecksToSource(publicizedNames); + } + + PublicizedReferences = outputs.ToArray(); + SupersededReferences = superseded.ToArray(); + return !Log.HasLoggedErrors; + } + + /// + /// Version stamp for the incrementality signature — a new TUnit.Mocks release re-publicizes + /// even when the source assembly is unchanged. + /// + private static readonly string TaskVersion = GetTaskVersion(); + + private static string GetTaskVersion() + { + var assembly = typeof(PublicizeAssemblyReferences).Assembly; + var informational = (System.Reflection.AssemblyInformationalVersionAttribute?)Attribute.GetCustomAttribute( + assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + return informational?.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "0"; + } + + /// + /// Resolves the requested simple name against the compiler's references. The documented + /// contract is the simple ASSEMBLY name, so a readable identity always decides: a renamed + /// asset (VendorAlias.dll holding identity Vendor.Core) matches, and a colliding file name + /// holding a DIFFERENT identity must not hide it. File-name matching survives in two places + /// only — references whose identity cannot be read ride along with the identity matches, and + /// when NO identity matches at all the file name is tried as a compatibility fallback (the + /// pre-existing behavior of requesting a renamed asset by its file name). + /// + private List FindReferenceMatches(string name) + { + var byIdentity = ReferencePaths.Where(r => + string.Equals(TryGetAssemblyIdentity(r.ItemSpec)?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (byIdentity.Count > 0) + { + byIdentity.AddRange(ReferencePaths.Where(r => + TryGetAssemblyIdentity(r.ItemSpec) is null && + string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase))); + return byIdentity; + } + + return ReferencePaths.Where(r => + string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + /// + /// Publicizes one identity group: the group's first member is the source, every member is + /// superseded (a leftover same-identity duplicate would collide with the publicized copy — + /// CS1703), and compiler-significant metadata from all members survives on the replacement. + /// + private void PublicizeGroup( + string name, List identityGroup, + List outputs, List superseded, List publicizedNames) + { + var reference = identityGroup[0]; + string source; + string destination; + string assemblyName; + + try + { + source = ResolveImplementationAssembly(name, reference); + // The runtime matches IgnoresAccessChecksTo against the real assembly identity, + // which can differ from the requested (file-derived) simple name. + assemblyName = System.Reflection.AssemblyName.GetAssemblyName(source).Name!; + // Each source gets its own subdirectory keyed by its path: two implementation + // assemblies with the same file name in different directories (renamed assets, or + // two identities behind one simple name) must not overwrite each other's copy. + var destinationDirectory = Path.Combine(OutputDirectory, HashPathToken(source)); + Directory.CreateDirectory(destinationDirectory); + destination = Path.Combine(destinationDirectory, Path.GetFileName(source)); + + // Content-based incrementality: the signature records the resolved source path + // and a hash of its bytes, so a replaced/downgraded assembly with an equal or + // older timestamp still invalidates the publicized copy. + // The task version is part of the signature: a TUnit.Mocks upgrade that changes + // the rewrite rules must invalidate copies produced by the previous task, or the + // compiler keeps seeing the stale publicized API until obj is cleaned. + var signaturePath = destination + ".sig"; + var signature = TaskVersion + "\n" + source + "\n" + HashFile(source); + + if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature) + { + Publicize(source, destination); + File.WriteAllText(signaturePath, signature); + Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'."); + } + else + { + Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date."); + } + } + catch (Exception ex) + { + Log.LogError( + subcategory: null, errorCode: "TUMIA005", helpKeyword: null, + file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0, + message: $"TUnitMocksInternalsAccess: failed to publicize '{reference.ItemSpec}': {ex.Message}"); + Log.LogMessage(MessageImportance.Low, ex.ToString()); + return; + } + + var item = new TaskItem(destination); + // Preserve the original reference's metadata (Aliases, EmbedInteropTypes, ...) — + // Csc reads compiler-significant options from item metadata, and an extern-aliased + // reference must stay extern-aliased after the swap. + reference.CopyMetadataTo(item); + if (identityGroup.Count > 1) + { + MergeCompilerMetadataFromDuplicates(item, identityGroup); + } + // "Original" is what the targets file Removes — the reference item as the compiler + // knew it (the ref assembly when one existed), not the implementation path. + item.SetMetadata("Original", reference.ItemSpec); + // Compile-time only: never copy the rewritten assembly to the output directory. + item.SetMetadata("Private", "false"); + item.SetMetadata("CopyLocal", "false"); + outputs.Add(item); + // Two identities behind one simple name produce one attribute application — the runtime + // matches IgnoresAccessChecksTo by simple name only. + if (!publicizedNames.Contains(assemblyName, StringComparer.OrdinalIgnoreCase)) + { + publicizedNames.Add(assemblyName); + } + + superseded.AddRange(identityGroup.Select(ITaskItem (m) => new TaskItem(m.ItemSpec))); + } + + /// + /// Groups the matches by exact assembly identity (name, version, public key), preserving + /// order — each group is publicized once from its first member. A match whose identity + /// cannot be read is never assumed to duplicate anything: the first match still anchors a + /// group (so a single unreadable reference fails loudly in Publicize), later unreadable + /// matches are left in the compiler's reference list untouched. + /// + private (List> Groups, List Unreadable) GroupMatchesByIdentity(List matches) + { + var groups = new List> { new() { matches[0] } }; + var groupIdentities = new List { TryGetAssemblyIdentity(matches[0].ItemSpec) }; + var unreadable = new List(); + + foreach (var match in matches.Skip(1)) + { + var identity = TryGetAssemblyIdentity(match.ItemSpec); + if (identity is null) + { + unreadable.Add(match); + continue; + } + + var groupIndex = groupIdentities.FindIndex(g => g is not null && HasSameIdentity(g, identity)); + if (groupIndex >= 0) + { + groups[groupIndex].Add(match); + } + else + { + groups.Add([match]); + groupIdentities.Add(identity); + } + } + + return (groups, unreadable); + } + + private static bool HasSameIdentity(System.Reflection.AssemblyName left, System.Reflection.AssemblyName right) + => string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase) + && Equals(left.Version, right.Version) + && (left.GetPublicKeyToken() ?? []).SequenceEqual(right.GetPublicKeyToken() ?? []); + + // Identity-first matching reads every reference's identity per requested name; cache the + // reads so each file is opened once per task invocation. + private readonly Dictionary _identityCache = new(StringComparer.Ordinal); + + private System.Reflection.AssemblyName? TryGetAssemblyIdentity(string path) + { + if (_identityCache.TryGetValue(path, out var cached)) + { + return cached; + } + + System.Reflection.AssemblyName? identity; + try + { + identity = System.Reflection.AssemblyName.GetAssemblyName(path); + } + catch (Exception) + { + // Native or otherwise unreadable dll. + identity = null; + } + + _identityCache[path] = identity; + return identity; + } + + /// + /// Every superseded (same-identity) match is removed from the compiler's reference list, so + /// compiler-significant metadata carried only by a non-selected match — an extern alias, an + /// EmbedInteropTypes flag — must survive on the single replacement item: aliases are + /// unioned, and interop embedding is kept if any match asked for it. + /// + private static void MergeCompilerMetadataFromDuplicates(TaskItem item, List matches) + { + var aliases = new List(); + var anyGlobal = false; + + foreach (var match in matches) + { + var value = match.GetMetadata("Aliases"); + if (string.IsNullOrWhiteSpace(value)) + { + // No aliases = visible through the global namespace. + anyGlobal = true; + continue; + } + + foreach (var raw in value.Split(',')) + { + var alias = raw.Trim(); + if (alias.Length == 0) + { + continue; + } + + if (alias == "global") + { + anyGlobal = true; + } + else if (!aliases.Contains(alias, StringComparer.Ordinal)) + { + aliases.Add(alias); + } + } + } + + if (aliases.Count > 0) + { + if (anyGlobal) + { + aliases.Insert(0, "global"); + } + + item.SetMetadata("Aliases", string.Join(",", aliases)); + } + + if (matches.Any(m => string.Equals(m.GetMetadata("EmbedInteropTypes"), "true", StringComparison.OrdinalIgnoreCase))) + { + item.SetMetadata("EmbedInteropTypes", "true"); + } + } + + /// + /// Picks the implementation assembly to publicize. Roslyn reference assemblies strip + /// internal members when the assembly grants no InternalsVisibleTo (internal types survive + /// as empty shells — e.g. an internal constructor would be gone), so a ref assembly is + /// useless as a publicizer source. ReferencePathWithRefAssemblies items carry the + /// implementation path as %(OriginalPath) when a ref assembly was substituted; when a + /// package supplies its compile asset from ref/<tfm> directly there is no OriginalPath, so + /// fall back to the runtime/copy-local assets to find the implementation. + /// + private string ResolveImplementationAssembly(string name, ITaskItem reference) + { + var originalPath = reference.GetMetadata("OriginalPath"); + if (!string.IsNullOrEmpty(originalPath)) + { + return originalPath; + } + + if (!IsReferenceAssembly(reference.ItemSpec)) + { + return reference.ItemSpec; + } + + var runtimeMatch = FindRuntimeImplementation(name, reference.ItemSpec); + + if (runtimeMatch is not null) + { + Log.LogMessage(MessageImportance.Normal, + $"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a reference assembly; " + + $"publicizing the implementation '{runtimeMatch}' instead."); + return runtimeMatch; + } + + Log.LogWarning( + subcategory: null, warningCode: "TUMIA003", helpKeyword: null, + file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0, + message: $"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a metadata-only reference assembly " + + "and no implementation assembly was found among the runtime assets. Internal members may " + + "already be stripped from it, in which case internals access will be incomplete for " + + $"'{name}'."); + return reference.ItemSpec; + } + + /// + /// Finds the implementation assembly among the runtime/copy-local assets by the reference's + /// real assembly identity — a full-identity match (name, public key token, version) wins, + /// with version drift alone tolerated as a fallback for imperfectly built packages. A + /// conflicting public key token is a DIFFERENT assembly that happens to share the simple + /// name (extern-alias pairs) and is never handed back — doing so would swap identities. + /// The requested-name filename match survives only as a fallback for a reference whose own + /// identity cannot be read. + /// + private string? FindRuntimeImplementation(string name, string referencePath) + { + var candidates = RuntimeAssemblies + .Select(r => r.ItemSpec) + .Where(p => + string.Equals(Path.GetExtension(p), ".dll", StringComparison.OrdinalIgnoreCase) && + File.Exists(p) && + // Never hand back the reference assembly itself if it also appears as an asset. + !string.Equals(Path.GetFullPath(p), Path.GetFullPath(referencePath), PathComparison)) + .ToList(); + + var identity = TryGetAssemblyIdentity(referencePath); + if (identity is null) + { + return candidates.FirstOrDefault(p => + string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase)); + } + + string? versionDriftMatch = null; + foreach (var candidate in candidates) + { + var candidateIdentity = TryGetAssemblyIdentity(candidate); + if (candidateIdentity is null) + { + // Native or otherwise unreadable dll among the copy-local assets. + continue; + } + + if (!string.Equals(candidateIdentity.Name, identity.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!(candidateIdentity.GetPublicKeyToken() ?? []).SequenceEqual(identity.GetPublicKeyToken() ?? [])) + { + continue; + } + + if (Equals(candidateIdentity.Version, identity.Version)) + { + return candidate; + } + + versionDriftMatch ??= candidate; + } + + return versionDriftMatch; + } + + /// + /// Path comparisons fold case only where the filesystem does: on a case-sensitive host, + /// /deps/A/Foo.dll and /deps/a/Foo.dll are distinct files, and folding would misjudge the + /// implementation as the reference assembly itself. + /// + private static StringComparison PathComparison => +#if NET + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +#else + // The net472 MSBuild host only runs on Windows. + StringComparison.OrdinalIgnoreCase; +#endif + + private static bool IsReferenceAssembly(string path) + { + using var module = ModuleDefinition.ReadModule(path); + return module.Assembly.HasCustomAttributes && module.Assembly.CustomAttributes.Any(a => + a.AttributeType.FullName == "System.Runtime.CompilerServices.ReferenceAssemblyAttribute"); + } + + /// + /// Stable per-source-path directory token — derived from the path (not content) so the same + /// source keeps the same output location across builds and incrementality still works. + /// Case is folded only where paths are case-insensitive: on a case-sensitive host, + /// /deps/A/Foo.dll and /deps/a/Foo.dll are DIFFERENT sources and folding would collapse + /// their tokens (and, with equal file names, their destinations). + /// + private static string HashPathToken(string sourcePath) + { + var fullPath = Path.GetFullPath(sourcePath); +#if NET + if (OperatingSystem.IsWindows()) + { + fullPath = fullPath.ToUpperInvariant(); + } +#else + // The net472 MSBuild host only runs on Windows. + fullPath = fullPath.ToUpperInvariant(); +#endif + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(fullPath)); +#if NET + return Convert.ToHexString(hash, 0, 4).ToLowerInvariant(); +#else + return BitConverter.ToString(hash, 0, 4).Replace("-", "").ToLowerInvariant(); +#endif + } + + private static string HashFile(string path) + { + using var sha = SHA256.Create(); + using var stream = File.OpenRead(path); +#if NET + return Convert.ToHexString(sha.ComputeHash(stream)); +#else + return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", ""); +#endif + } + + private static void Publicize(string source, string destination) + { + using var module = ModuleDefinition.ReadModule(source); + + foreach (var type in module.GetTypes()) + { + if (type.Name == "") + { + continue; + } + + // Only assembly-level visibility is promoted. private/protected members stay as + // they are: the feature promises INTERNALS access, and promoting private members + // would inject them into overload resolution for consuming code (a formerly-private + // M(string) beside a public M(object) silently rebinding M(null)). + if (IsAssemblyVisibleType(type)) + { + type.Attributes = type.IsNested + ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedPublic + : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.Public; + } + + foreach (var method in type.Methods) + { + if (IsAssemblyVisibleMethod(method)) + { + method.Attributes = (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public; + } + } + } + + // Identity (name/version/public key) is preserved so compiled IL binds to the original + // assembly at runtime; only the signature is invalidated, which nothing validates for a + // compile-time reference. Clear the signed flag so nothing is tempted to try. + module.Attributes &= ~ModuleAttributes.StrongNameSigned; + module.Write(destination); + } + + /// internal, protected internal, or private protected — never plain private. + private static bool IsAssemblyVisibleType(TypeDefinition type) + => type.IsNested + ? type.IsNestedAssembly || type.IsNestedFamilyOrAssembly || type.IsNestedFamilyAndAssembly + : type.IsNotPublic; + + private static bool IsAssemblyVisibleMethod(MethodDefinition method) + => method.IsAssembly || method.IsFamilyOrAssembly || method.IsFamilyAndAssembly; + + private void WriteIgnoresAccessChecksToSource(List assemblyNames) + { + var writer = new StringWriter(); + writer.WriteLine("// "); + writer.WriteLine("// Generated by TUnit.Mocks internals access (experimental). The runtime honors"); + writer.WriteLine("// IgnoresAccessChecksToAttribute and skips accessibility checks from this assembly"); + writer.WriteLine("// to the assemblies named below, matching the publicized compile-time references."); + writer.WriteLine("// "); + foreach (var name in assemblyNames) + { + writer.WriteLine($"[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo(\"{name}\")]"); + } + + if (EmitAttributeDefinition) + { + writer.WriteLine(); + writer.WriteLine("namespace System.Runtime.CompilerServices"); + writer.WriteLine("{"); + writer.WriteLine(" [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]"); + writer.WriteLine(" internal sealed class IgnoresAccessChecksToAttribute : Attribute"); + writer.WriteLine(" {"); + writer.WriteLine(" public IgnoresAccessChecksToAttribute(string assemblyName) => AssemblyName = assemblyName;"); + writer.WriteLine(); + writer.WriteLine(" public string AssemblyName { get; }"); + writer.WriteLine(" }"); + writer.WriteLine("}"); + } + + var content = writer.ToString(); + Directory.CreateDirectory(Path.GetDirectoryName(GeneratedSourceFile)!); + if (!File.Exists(GeneratedSourceFile) || File.ReadAllText(GeneratedSourceFile) != content) + { + File.WriteAllText(GeneratedSourceFile, content); + } + } +} diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md new file mode 100644 index 0000000000..0183f2dd2e --- /dev/null +++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md @@ -0,0 +1,111 @@ +# TUnit.Mocks internals access (experimental) + +The Tier 2 answer to [#6514](https://github.com/thomhurst/TUnit/issues/6514): make types that are +`internal` to another assembly first-class mockable — nameable in test code, source-generated +typed mocks, setups, matchers, and verification — even when that assembly grants **no** +`InternalsVisibleTo` at all. Complements the runtime auto-stubs from PR #6519 (Tier 1), which are +zero-config but anonymous: a stub satisfies an SDK's internal `Get()` calls, yet the test can +never configure or verify a type it cannot name. + +## Usage + +Ships inside the `TUnit.Mocks` package; consumers only need the opt-in: + +```xml + + true + + + + + + +``` + +## How it works + +The established "publicizer" pattern (Krafs.Publicizer, IgnoresAccessChecksToGenerator), wired +for TUnit.Mocks: + +1. `PublicizeAssemblyReferences` (Mono.Cecil) rewrites each requested reference so its internal + types and members are public, preserving the assembly identity (name, version, public key). + The **implementation** assembly is used as the source — Roslyn reference assemblies strip + internal members (e.g. internal constructors) when no `InternalsVisibleTo` exists, so + publicizing a ref assembly would yield empty shells. `%(OriginalPath)` supplies the + implementation when MSBuild substituted a ref assembly; when a package ships its compile + asset from `ref/` directly (no `OriginalPath`), the implementation is located among the + runtime/copy-local assets instead (warning `TUMIA003` if none is found). Copies live under + `obj/` only, invalidated by content hash (not timestamps), so downgrades and equal-timestamp + replacements re-publicize. +2. `TUnit.Mocks.InternalsAccess.targets` (imported by `TUnit.Mocks.targets`, fully inert without + the opt-in) swaps the copies into `ReferencePathWithRefAssemblies` — the item group that feeds + the compiler and nothing else. The replacement item carries the original reference's metadata + (`Aliases`, `EmbedInteropTypes`, ...) so extern-aliased references stay aliased. + `ReferencePath` is untouched, so copy-local output and `deps.json` keep the original assembly + and the runtime binds to it. +3. The task emits `[assembly: IgnoresAccessChecksTo(...)]` (plus the attribute definition) into + the compilation; the runtime honors it and skips accessibility checks, so generated mock + classes implementing internal interfaces load and run against the original assembly. If + another package already defines `IgnoresAccessChecksToAttribute` in the compilation, set + `false` to suppress TUnit's copy. +4. The TUnit.Mocks source generator needs no changes: through the publicized reference the + internal types simply look public — discovery, TM007 accessibility checks, and emission all + behave as for any public type. + +## Layout + +- Task assembly: `tasks/net472/` (Visual Studio's .NET Framework MSBuild) and `tasks/net8.0/` + (`dotnet msbuild`), each with `Mono.Cecil.dll` beside it; selected via `$(MSBuildRuntimeType)`. +- Targets: `buildTransitive//TUnit.Mocks.InternalsAccess.targets`, imported from + `TUnit.Mocks.targets`. Repo-local builds resolve the task from this project's bin instead. + +## Caveats + +- `IgnoresAccessChecksToAttribute` is honored by CoreCLR but is not a documented public contract + (it underpins several long-lived OSS packages; breakage risk is low but nonzero). +- .NET Framework test targets are unsupported (warning `TUMIA002`, pipeline stays inert). +- Verified under `PublishTrimmed` (full trim mode); Native AOT not yet verified. +- Publicizer scope: assembly-visible types and methods only — `internal`, `protected internal`, + and `private protected` become `public` (constructors and accessors included). `private` and + `protected` members are untouched: the feature promises internals access, and promoting a + private overload would silently change overload resolution in consuming code. Fields are left + alone; explicit interface implementations stay private as IL requires. +- Dev loop: MSBuild nodes hold the task assembly's file lock across builds — run + `dotnet build-server shutdown` after changing this project. +- Diagnostics: `TUMIA001` unresolved assembly name (error; the requested name is matched by + file name first, then by the reference's real assembly identity, so renamed compile assets + resolve), `TUMIA002` .NET Framework inert (warning), `TUMIA003` ref-assembly-only source + (warning), `TUMIA004` ambiguous simple-name match — each distinct assembly identity + (version/public key — a legal pair only under extern aliases) gets its own publicized copy, + while same-identity duplicates collapse onto one copy with extern aliases unioned and + `EmbedInteropTypes` kept if any set it; all matched references are removed from the compiler's + list so nothing collides with the publicized copies. Matches whose identity cannot be read are + left in place untouched (warning), `TUMIA005` publicize failure, e.g. unreadable/locked + assembly (error). +- Incrementality is content-based (source path + SHA-256) and versioned: upgrading TUnit.Mocks + re-publicizes even when the referenced assembly is unchanged, so rewrite-rule fixes are never + masked by a stale `obj` copy. + +## Why a custom task instead of depending on Krafs.Publicizer / IgnoresAccessChecksToGenerator + +Those packages are consumer-facing: each wires its own MSBuild entry points, props/targets, and +item vocabulary into the referencing project. TUnit.Mocks needs the publicize step to ride +inside its own package transparently (single opt-in property, transitive `buildTransitive` +import, dual net472/net8.0 MSBuild hosts, `ReferencePathWithRefAssemblies`-only swap so +`deps.json` stays clean) — none of which those packages expose as a library API; both would +arrive as additional package references with their own configuration surface for users to +manage. The rewrite itself (~60 lines of Cecil) is deliberately narrower than theirs: types and +methods only, no fields, no `IgnoresAccessChecksTo`-free mode. If the scope ever grows toward +theirs, vendoring or depending on one should be revisited. + +## Validation + +- `tests/TUnit.Mocks.InternalsAccess.TargetLib` — a strong-named stand-in SDK with an internal + interface, internal generic interface, internal class (internal constructor), and public + generic accessors called from SDK-internal code; deliberately **zero** IVT grants. +- `tests/TUnit.Mocks.InternalsAccess.Tests` — end-to-end pipeline tests (naming, mocking, typed + setup/matcher/verification, partial-mocking the internal class, manual implementations) plus + unit tests of the task itself (publicizing, identity preservation, incrementality, generated + source, `TUMIA001` errors). Runs in CI via `RunMockInternalsAccessTestsModule`. +- Packaged-layout and trimmed-publish verification were exercised against the produced nupkg + (extracted `buildTransitive` import + `PublishTrimmed=true` console consumer). diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj new file mode 100644 index 0000000000..e402b29b0e --- /dev/null +++ b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj @@ -0,0 +1,23 @@ + + + + + net472;net8.0 + false + + true + true + + + + + + + + + diff --git a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets new file mode 100644 index 0000000000..0b16aa073a --- /dev/null +++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets @@ -0,0 +1,74 @@ + + + + + <_TUnitMocksInternalsAccessTasksTfm Condition="'$(MSBuildRuntimeType)' == 'Core'">net8.0 + <_TUnitMocksInternalsAccessTasksTfm Condition="'$(_TUnitMocksInternalsAccessTasksTfm)' == ''">net472 + + + $(MSBuildThisFileDirectory)..\..\tasks\$(_TUnitMocksInternalsAccessTasksTfm)\TUnit.Mocks.InternalsAccess.Tasks.dll + + + $(MSBuildThisFileDirectory)..\TUnit.Mocks.InternalsAccess.Tasks\bin\$(Configuration)\$(_TUnitMocksInternalsAccessTasksTfm)\TUnit.Mocks.InternalsAccess.Tasks.dll + + + + + + + + + + + + <_TUnitMocksIactSourceFile>$(IntermediateOutputPath)TUnitMocksIgnoresAccessChecksTo.g.cs + + true + + + + + + + + + + + + + + + + + + diff --git a/src/TUnit.Mocks/TUnit.Mocks.csproj b/src/TUnit.Mocks/TUnit.Mocks.csproj index 1eb8cfb2bc..ebbae89f15 100644 --- a/src/TUnit.Mocks/TUnit.Mocks.csproj +++ b/src/TUnit.Mocks/TUnit.Mocks.csproj @@ -50,6 +50,43 @@ true $(BuildPath) + + + true + $(BuildTransitivePath) + + + + true + $(BuildPath) + + + + + + $(NoWarn);NU5100 + + + + + + + + + + diff --git a/src/TUnit.Mocks/TUnit.Mocks.targets b/src/TUnit.Mocks/TUnit.Mocks.targets index 96c60220a6..be51c2ba0e 100644 --- a/src/TUnit.Mocks/TUnit.Mocks.targets +++ b/src/TUnit.Mocks/TUnit.Mocks.targets @@ -5,4 +5,8 @@ + + + diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs new file mode 100644 index 0000000000..d8c0655e60 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs @@ -0,0 +1,94 @@ +namespace FakeSdk; + +/// +/// Mirrors the shape from issue #6514: a public generic accessor whose type arguments are chosen +/// by SDK-internal code, using types the consuming assembly cannot normally name. +/// +public interface IFeatureCollection +{ + T Get(); +} + +/// +/// The unnameable type. Internal, and this assembly grants no InternalsVisibleTo whatsoever. +/// +internal interface IInternalBindingsFeature +{ + string InvocationResult { get; set; } + + int Compute(int seed); +} + +/// +/// An internal generic interface: closed instantiations must be mockable too. +/// +internal interface IInternalRepository +{ + T Load(int id); +} + +/// +/// An internal class with virtual members and an internal constructor — the partial-mock shape. +/// +internal class InternalWidget +{ + internal InternalWidget() + { + } + + public virtual string Name => "real-widget"; + + public virtual int Weight() => 100; +} + +/// +/// Overload-resolution guard for the publicizer: only assembly-visible members may be promoted. +/// If the private Describe(string) overload became public, a consumer's Describe(null) would +/// silently rebind from the object overload to it. +/// +public class PublicSurface +{ + public string Describe(object? value) => "object"; + + private string Describe(string? value) => "string"; + + internal string InternalHelper() => "internal"; + + protected virtual string ProtectedHook() => "protected"; + + public string UsePrivates() => Describe((string?)null) + Describe((object?)null) + ProtectedHook(); + + private sealed class PrivateNested; + + internal sealed class InternalNested; +} + +/// +/// Simulates SDK-internal call sites the test has no control over. +/// +public static class SdkRuntime +{ + public static string DescribeRepository(IFeatureCollection features) + { + var repository = features.Get>(); + return repository is null ? "" : repository.Load(1); + } + + public static string DescribeWidget(IFeatureCollection features) + { + var widget = features.Get(); + return widget is null ? "" : $"{widget.Name}:{widget.Weight()}"; + } + + public static string DescribeInvocation(IFeatureCollection features) + { + var bindings = features.Get(); + return bindings is null ? "" : bindings.InvocationResult; + } + + public static int RunComputation(IFeatureCollection features, int seed) + { + var bindings = features.Get(); + return bindings?.Compute(seed) ?? -1; + } +} diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj new file mode 100644 index 0000000000..8aa5d27690 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj @@ -0,0 +1,15 @@ + + + + + net10.0 + false + true + ..\..\eng\strongname.snk + + + diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs new file mode 100644 index 0000000000..7d97d13bd6 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs @@ -0,0 +1,102 @@ +using FakeSdk; +using TUnit.Mocks; +using TUnit.Mocks.Verification; + +namespace TUnit.Mocks.InternalsAccess.Tests; + +// Experimental #6514 "Tier 2": IInternalBindingsFeature is internal to the FakeSdk assembly, +// which grants NO InternalsVisibleTo (not to this assembly, not to DynamicProxyGenAssembly2). +// With TUnitMocksExperimentalInternalsAccess enabled, the compiler sees a publicized copy of the +// reference and IgnoresAccessChecksTo makes it valid at runtime — so the type is nameable here, +// the source generator mocks it like any public interface, and setups/verification are fully +// typed. This exceeds what runtime-proxy libraries offer: they can auto-substitute such a type +// but can never let the test configure or verify it, because the test cannot write its name. + +public class InternalsAccessTests +{ + [Test] + public async Task Internal_Interface_Is_Nameable_And_Mockable() + { + var bindings = IInternalBindingsFeature.Mock(); + bindings.InvocationResult.Returns("configured"); + + await Assert.That(bindings.Object.InvocationResult).IsEqualTo("configured"); + } + + [Test] + public async Task Sdk_Internal_Generic_Request_Receives_The_Configured_Mock() + { + var bindings = IInternalBindingsFeature.Mock(); + bindings.InvocationResult.Returns("from-tier2"); + + var features = IFeatureCollection.Mock(); + features.Get().Returns(bindings.Object); + + // The generic request happens inside the SDK, not in this assembly. + await Assert.That(SdkRuntime.DescribeInvocation(features.Object)).IsEqualTo("from-tier2"); + + // Typed verification of a call whose type argument is internal to another assembly. + features.Get().WasCalled(Times.Once); + } + + [Test] + public async Task Typed_Setup_With_Matchers_On_Internal_Member() + { + var bindings = IInternalBindingsFeature.Mock(); + bindings.Compute(Any()).Returns(seed => seed * 2); + + var features = IFeatureCollection.Mock(); + features.Get().Returns(bindings.Object); + + await Assert.That(SdkRuntime.RunComputation(features.Object, 21)).IsEqualTo(42); + + bindings.Compute(21).WasCalled(Times.Once); + bindings.Compute(99).WasNeverCalled(); + } + + [Test] + public async Task Closed_Generic_Internal_Interface_Is_Mockable() + { + var repository = IInternalRepository.Mock(); + repository.Load(Any()).Returns(id => $"row-{id}"); + + var features = IFeatureCollection.Mock(); + features.Get>().Returns(repository.Object); + + await Assert.That(SdkRuntime.DescribeRepository(features.Object)).IsEqualTo("row-1"); + } + + [Test] + public async Task Internal_Class_Is_Partial_Mockable() + { + // Partial-mock shape: internal class, virtual members, internal constructor — all + // publicized for the compiler, honored by IgnoresAccessChecksTo at runtime. + var widget = InternalWidget.Mock(); + widget.Name.Returns("mocked"); + + var features = IFeatureCollection.Mock(); + features.Get().Returns(widget.Object); + + // Weight() is unconfigured, so the virtual base implementation runs. + await Assert.That(SdkRuntime.DescribeWidget(features.Object)).IsEqualTo("mocked:100"); + } + + [Test] + public async Task Manual_Implementation_Of_The_Internal_Interface_Loads_And_Runs() + { + // Proves IgnoresAccessChecksTo is honored at type-load time for hand-written + // implementations too, not just generated mocks. + var features = IFeatureCollection.Mock(); + features.Get().Returns(new ManualBindings()); + + await Assert.That(SdkRuntime.DescribeInvocation(features.Object)).IsEqualTo("manual"); + await Assert.That(SdkRuntime.RunComputation(features.Object, 5)).IsEqualTo(6); + } + + private sealed class ManualBindings : IInternalBindingsFeature + { + public string InvocationResult { get; set; } = "manual"; + + public int Compute(int seed) => seed + 1; + } +} diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs new file mode 100644 index 0000000000..700aa682e9 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs @@ -0,0 +1,701 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using TUnit.Mocks.InternalsAccess.Tasks; +using Task = System.Threading.Tasks.Task; + +namespace TUnit.Mocks.InternalsAccess.Tests; + +// Unit tests for the publicizer task itself: rewriting, incrementality, the generated +// IgnoresAccessChecksTo source, and error behavior. The end-to-end pipeline (targets wiring, +// compiler swap, runtime behavior) is covered by InternalsAccessTests. + +public class PublicizeAssemblyReferencesTaskTests +{ + private const string TargetLibName = "TUnit.Mocks.InternalsAccess.TargetLib"; + + private static string TargetLibPath => + Path.Combine(AppContext.BaseDirectory, TargetLibName + ".dll"); + + private static PublicizeAssemblyReferences CreateTask(string outputDirectory, params string[] names) + => new() + { + BuildEngine = new StubBuildEngine(), + ReferencePaths = [new TaskItem(TargetLibPath), new TaskItem(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"))], + AssembliesToPublicize = names.Select(ITaskItem (n) => new TaskItem(n)).ToArray(), + OutputDirectory = outputDirectory, + GeneratedSourceFile = Path.Combine(outputDirectory, "iact.g.cs"), + }; + + private static string NewScratchDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "tunit-mocks-ia-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + [Test] + public async Task Publicizes_Internal_Types_And_Preserves_Identity() + { + var dir = NewScratchDirectory(); + var task = CreateTask(dir, TargetLibName); + + await Assert.That(task.Execute()).IsTrue(); + + var rewritten = task.PublicizedReferences[0].ItemSpec; + await Assert.That(File.Exists(rewritten)).IsTrue(); + + var context = new AssemblyLoadContext("publicized-probe", isCollectible: true); + try + { + var assembly = context.LoadFromAssemblyPath(rewritten); + + var internalInterface = assembly.GetType("FakeSdk.IInternalBindingsFeature", throwOnError: true)!; + await Assert.That(internalInterface.IsPublic).IsTrue(); + + var internalClass = assembly.GetType("FakeSdk.InternalWidget", throwOnError: true)!; + await Assert.That(internalClass.IsPublic).IsTrue(); + var constructor = internalClass.GetConstructors(BindingFlags.Instance | BindingFlags.Public); + await Assert.That(constructor.Length).IsEqualTo(1); + + // Identity must match the original, strong-name public key included, so compiled IL + // binds to the real assembly at runtime. + var original = AssemblyName.GetAssemblyName(TargetLibPath); + var publicized = assembly.GetName(); + await Assert.That(publicized.FullName).IsEqualTo(original.FullName); + await Assert.That(publicized.GetPublicKeyToken()).IsEquivalentTo(original.GetPublicKeyToken()!); + } + finally + { + context.Unload(); + } + } + + [Test] + public async Task Generates_IgnoresAccessChecksTo_Source_For_All_Requested_Assemblies() + { + var dir = NewScratchDirectory(); + var task = CreateTask(dir, TargetLibName, "TUnit.Mocks"); + + await Assert.That(task.Execute()).IsTrue(); + + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")"); + await Assert.That(source).Contains("IgnoresAccessChecksTo(\"TUnit.Mocks\")"); + await Assert.That(source).Contains("class IgnoresAccessChecksToAttribute"); + + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Private")).IsEqualTo("false"); + } + + [Test] + public async Task Second_Run_Is_Incremental() + { + var dir = NewScratchDirectory(); + + var first = CreateTask(dir, TargetLibName); + await Assert.That(first.Execute()).IsTrue(); + + var rewritten = first.PublicizedReferences[0].ItemSpec; + var firstWrite = File.GetLastWriteTimeUtc(rewritten); + var firstSourceWrite = File.GetLastWriteTimeUtc(first.GeneratedSourceFile); + + var second = CreateTask(dir, TargetLibName); + await Assert.That(second.Execute()).IsTrue(); + + await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsEqualTo(firstWrite); + await Assert.That(File.GetLastWriteTimeUtc(second.GeneratedSourceFile)).IsEqualTo(firstSourceWrite); + } + + [Test] + public async Task Content_Change_Invalidates_Publicized_Copy_Despite_Older_Timestamp() + { + var dir = NewScratchDirectory(); + + // Use a private copy of the source so its content/timestamp can be manipulated. + var sourceDir = NewScratchDirectory(); + var source = Path.Combine(sourceDir, TargetLibName + ".dll"); + File.Copy(TargetLibPath, source); + + var first = CreateTask(dir, TargetLibName); + first.ReferencePaths = [new TaskItem(source)]; + await Assert.That(first.Execute()).IsTrue(); + + var rewritten = first.PublicizedReferences[0].ItemSpec; + var firstWrite = File.GetLastWriteTimeUtc(rewritten); + + // Different content, timestamp pushed BEFORE the publicized copy — a timestamp-only + // check would treat the output as up to date. + File.Copy(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"), source, overwrite: true); + File.SetLastWriteTimeUtc(source, firstWrite.AddMinutes(-5)); + + var second = CreateTask(dir, TargetLibName); + second.ReferencePaths = [new TaskItem(source)]; + await Assert.That(second.Execute()).IsTrue(); + + await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsNotEqualTo(firstWrite); + } + + [Test] + public async Task Replacement_Reference_Preserves_Compiler_Metadata() + { + var dir = NewScratchDirectory(); + var reference = new TaskItem(TargetLibPath); + reference.SetMetadata("Aliases", "sdkalias"); + reference.SetMetadata("EmbedInteropTypes", "false"); + + var task = CreateTask(dir, TargetLibName); + task.ReferencePaths = [reference]; + + await Assert.That(task.Execute()).IsTrue(); + + var publicized = task.PublicizedReferences[0]; + await Assert.That(publicized.GetMetadata("Aliases")).IsEqualTo("sdkalias"); + await Assert.That(publicized.GetMetadata("EmbedInteropTypes")).IsEqualTo("false"); + // The overrides still win over copied metadata. + await Assert.That(publicized.GetMetadata("Private")).IsEqualTo("false"); + await Assert.That(publicized.GetMetadata("CopyLocal")).IsEqualTo("false"); + } + + [Test] + public async Task Attribute_Definition_Can_Be_Suppressed() + { + var dir = NewScratchDirectory(); + var task = CreateTask(dir, TargetLibName); + task.EmitAttributeDefinition = false; + + await Assert.That(task.Execute()).IsTrue(); + + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")"); + await Assert.That(source).DoesNotContain("class IgnoresAccessChecksToAttribute"); + } + + [Test] + public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004() + { + var dir = NewScratchDirectory(); + var duplicateDir = NewScratchDirectory(); + var duplicate = Path.Combine(duplicateDir, TargetLibName + ".dll"); + File.Copy(TargetLibPath, duplicate); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(TargetLibPath), new TaskItem(duplicate)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(1); + await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004"); + // Deterministic: first match wins. + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath); + // ALL matches are superseded — a leftover duplicate would carry the same assembly + // identity as the publicized copy and break the compile with CS1703. + var supersededPaths = task.SupersededReferences.Select(s => s.ItemSpec).ToList(); + await Assert.That(supersededPaths).Contains(TargetLibPath); + await Assert.That(supersededPaths).Contains(duplicate); + } + + [Test] + public async Task Duplicate_Requests_Produce_Single_Publicized_Reference() + { + var dir = NewScratchDirectory(); + // Same simple name requested twice (case difference included) — e.g. duplicated items + // from a multi-imported props file. The compiler must see exactly one publicized copy. + var task = CreateTask(dir, TargetLibName, TargetLibName.ToUpperInvariant()); + + await Assert.That(task.Execute()).IsTrue(); + + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(1); + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + var applications = source.Split([$"IgnoresAccessChecksTo(\"{TargetLibName}\")"], StringSplitOptions.None).Length - 1; + await Assert.That(applications).IsEqualTo(1); + } + + [Test] + public async Task Ambiguous_Matches_Union_Compiler_Metadata() + { + var dir = NewScratchDirectory(); + var duplicateDir = NewScratchDirectory(); + var duplicatePath = Path.Combine(duplicateDir, TargetLibName + ".dll"); + File.Copy(TargetLibPath, duplicatePath); + + // The alias and interop flag live ONLY on the non-selected match. Both matches leave + // the compiler's reference list, so the replacement item must carry them or an existing + // `extern alias sdkalias` in the consuming project stops resolving. + var winner = new TaskItem(TargetLibPath); + var duplicate = new TaskItem(duplicatePath); + duplicate.SetMetadata("Aliases", "sdkalias"); + duplicate.SetMetadata("EmbedInteropTypes", "true"); + + var task = CreateTask(dir, TargetLibName); + task.ReferencePaths = [winner, duplicate]; + + await Assert.That(task.Execute()).IsTrue(); + + var publicized = task.PublicizedReferences[0]; + // The winner had no aliases (global visibility) — that must survive the union too. + await Assert.That(publicized.GetMetadata("Aliases")).IsEqualTo("global,sdkalias"); + await Assert.That(publicized.GetMetadata("EmbedInteropTypes")).IsEqualTo("true"); + } + + [Test] + public async Task Different_Identity_Same_Name_References_Are_Each_Publicized() + { + var dir = NewScratchDirectory(); + var otherVersionDir = NewScratchDirectory(); + var otherVersionPath = Path.Combine(otherVersionDir, TargetLibName + ".dll"); + CreateDifferentVersionCopy(TargetLibPath, otherVersionPath); + + // A same-file-name pair with DIFFERENT identities is legal only under extern aliases, + // and the simple-name request cannot single one out — so each identity gets its own + // publicized copy, with per-identity metadata (the alias) preserved on its own item. + var winner = new TaskItem(TargetLibPath); + var otherVersion = new TaskItem(otherVersionPath); + otherVersion.SetMetadata("Aliases", "oldsdk"); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [winner, otherVersion]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(1); + await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004"); + + // Both identities are superseded — each is replaced by its own publicized copy. + var supersededPaths = task.SupersededReferences.Select(s => s.ItemSpec).ToList(); + await Assert.That(supersededPaths).Contains(TargetLibPath); + await Assert.That(supersededPaths).Contains(otherVersionPath); + + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath); + await Assert.That(task.PublicizedReferences[1].GetMetadata("Original")).IsEqualTo(otherVersionPath); + // Aliases stay with their own identity — never merged across identities. + await Assert.That(task.PublicizedReferences[0].GetMetadata("Aliases")).IsEqualTo(""); + await Assert.That(task.PublicizedReferences[1].GetMetadata("Aliases")).IsEqualTo("oldsdk"); + + // Same file name from different directories — the copies must not overwrite each other, + // and each must keep its own identity. + var copyPaths = task.PublicizedReferences.Select(p => p.ItemSpec).ToList(); + await Assert.That(copyPaths[0]).IsNotEqualTo(copyPaths[1]); + await Assert.That(AssemblyName.GetAssemblyName(copyPaths[0]).Version) + .IsEqualTo(AssemblyName.GetAssemblyName(TargetLibPath).Version); + await Assert.That(AssemblyName.GetAssemblyName(copyPaths[1]).Version).IsEqualTo(new Version(99, 0, 0, 0)); + + // One simple name -> one IgnoresAccessChecksTo application, even with two identities. + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + var applications = source.Split([$"IgnoresAccessChecksTo(\"{TargetLibName}\")"], StringSplitOptions.None).Length - 1; + await Assert.That(applications).IsEqualTo(1); + } + + [Test] + public async Task Same_File_Name_Sources_From_Different_Directories_Get_Distinct_Outputs() + { + var dir = NewScratchDirectory(); + var dirA = NewScratchDirectory(); + var dirB = NewScratchDirectory(); + + // Two opted-in references whose compile assets share a file name but hold different + // assemblies (renamed assets) — their publicized copies must not collide. + var renamedTargetLib = Path.Combine(dirA, "Common.dll"); + File.Copy(TargetLibPath, renamedTargetLib); + var renamedMocks = Path.Combine(dirB, "Common.dll"); + File.Copy(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"), renamedMocks); + + var task = CreateTask(dir, TargetLibName, "TUnit.Mocks"); + task.ReferencePaths = [new TaskItem(renamedTargetLib), new TaskItem(renamedMocks)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2); + + var copyPaths = task.PublicizedReferences.Select(p => p.ItemSpec).ToList(); + await Assert.That(copyPaths[0]).IsNotEqualTo(copyPaths[1]); + // Each copy holds the assembly it was publicized from, not the other name's overwrite. + var identities = copyPaths.Select(p => AssemblyName.GetAssemblyName(p).Name).ToList(); + await Assert.That(identities).Contains(TargetLibName); + await Assert.That(identities).Contains("TUnit.Mocks"); + } + + [Test] + public async Task Requested_Name_Matches_Assembly_Identity_When_Compile_Asset_Is_Renamed() + { + var dir = NewScratchDirectory(); + var renamedDir = NewScratchDirectory(); + var renamed = Path.Combine(renamedDir, "VendorAlias.dll"); + File.Copy(TargetLibPath, renamed); + + // The documented contract is the simple ASSEMBLY name — a compile asset renamed + // relative to the identity it contains must still resolve, not fail with TUMIA001. + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(renamed)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Errors.Count).IsEqualTo(0); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(renamed); + + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")"); + } + + private static void CreateDifferentVersionCopy(string source, string destination) + { + using var module = Mono.Cecil.ModuleDefinition.ReadModule(source); + module.Assembly.Name.Version = new Version(99, 0, 0, 0); + module.Write(destination); + } + + [Test] + public async Task Task_Version_Change_Invalidates_Publicized_Copy() + { + var dir = NewScratchDirectory(); + + var first = CreateTask(dir, TargetLibName); + await Assert.That(first.Execute()).IsTrue(); + + var rewritten = first.PublicizedReferences[0].ItemSpec; + var signaturePath = rewritten + ".sig"; + + // Emulate a copy produced by an older TUnit.Mocks: same source path and hash, different + // task version on the first signature line. + var lines = (await File.ReadAllTextAsync(signaturePath)).Split('\n'); + lines[0] = "0.0.0-previous-task-version"; + await File.WriteAllTextAsync(signaturePath, string.Join("\n", lines)); + + var staleWrite = File.GetLastWriteTimeUtc(rewritten).AddMinutes(-5); + File.SetLastWriteTimeUtc(rewritten, staleWrite); + + var second = CreateTask(dir, TargetLibName); + await Assert.That(second.Execute()).IsTrue(); + + await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsNotEqualTo(staleWrite); + } + + [Test] + public async Task Publicize_Promotes_Only_Assembly_Visible_Members() + { + var dir = NewScratchDirectory(); + var task = CreateTask(dir, TargetLibName); + + await Assert.That(task.Execute()).IsTrue(); + + var context = new AssemblyLoadContext("visibility-probe", isCollectible: true); + try + { + var assembly = context.LoadFromAssemblyPath(task.PublicizedReferences[0].ItemSpec); + var surface = assembly.GetType("FakeSdk.PublicSurface", throwOnError: true)!; + + // internal -> public. + var internalHelper = surface.GetMethod("InternalHelper", BindingFlags.Instance | BindingFlags.Public); + await Assert.That(internalHelper).IsNotNull(); + + // private overload stays private — promoting it would rebind Describe(null) in + // consuming code from the object overload to the string overload. + var describeString = surface.GetMethod( + "Describe", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, [typeof(string)], modifiers: null)!; + await Assert.That(describeString.IsPrivate).IsTrue(); + + // protected stays protected — already accessible where it matters (derived mocks). + var protectedHook = surface.GetMethod("ProtectedHook", BindingFlags.Instance | BindingFlags.NonPublic)!; + await Assert.That(protectedHook.IsFamily).IsTrue(); + + // Nested types: internal -> public, private stays private. + var internalNested = surface.GetNestedType("InternalNested", BindingFlags.Public); + await Assert.That(internalNested).IsNotNull(); + var privateNested = surface.GetNestedType("PrivateNested", BindingFlags.NonPublic)!; + await Assert.That(privateNested.IsNestedPrivate).IsTrue(); + } + finally + { + context.Unload(); + } + } + + [Test] + public async Task Generated_Source_Uses_Real_Assembly_Identity_When_File_Name_Differs() + { + var dir = NewScratchDirectory(); + var renamedDir = NewScratchDirectory(); + var renamed = Path.Combine(renamedDir, "Renamed.Lib.dll"); + File.Copy(TargetLibPath, renamed); + + var task = CreateTask(dir, "Renamed.Lib"); + task.ReferencePaths = [new TaskItem(renamed)]; + + await Assert.That(task.Execute()).IsTrue(); + + // The runtime matches IgnoresAccessChecksTo against the assembly's real identity, not + // the file name the reference was requested by. + var source = await File.ReadAllTextAsync(task.GeneratedSourceFile); + await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")"); + await Assert.That(source).DoesNotContain("IgnoresAccessChecksTo(\"Renamed.Lib\")"); + } + + [Test] + public async Task Unreadable_Assembly_Fails_With_TUMIA005_Not_An_Unhandled_Exception() + { + var dir = NewScratchDirectory(); + var garbage = Path.Combine(dir, "Garbage.Assembly.dll"); + await File.WriteAllTextAsync(garbage, "this is not a PE file"); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, "Garbage.Assembly"); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(garbage)]; + + await Assert.That(task.Execute()).IsFalse(); + await Assert.That(engine.Errors.Count).IsEqualTo(1); + await Assert.That(engine.Errors[0].Code).IsEqualTo("TUMIA005"); + } + + [Test] + public async Task Reference_Assembly_Without_Implementation_Warns_With_TUMIA003() + { + var dir = NewScratchDirectory(); + var refAsmDir = NewScratchDirectory(); + var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll"); + CreateReferenceAssemblyCopy(TargetLibPath, refAsm); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(refAsm)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(1); + await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA003"); + } + + [Test] + public async Task Reference_Assembly_Falls_Back_To_Runtime_Implementation() + { + var dir = NewScratchDirectory(); + var refAsmDir = NewScratchDirectory(); + var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll"); + CreateReferenceAssemblyCopy(TargetLibPath, refAsm); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(refAsm)]; + task.RuntimeAssemblies = [new TaskItem(TargetLibPath)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(0); + + // The swap must still Remove the compiler's item (the ref assembly), while the + // publicized bits come from the implementation. + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(refAsm); + } + + [Test] + public async Task Renamed_Reference_Assembly_Resolves_Implementation_By_Assembly_Identity() + { + // The compile asset's file name can differ from the assembly identity it contains; the + // implementation then sits among the runtime assets under the REAL name, so a + // file-name-only match would miss it and publicize the stripped ref assembly (TUMIA003). + var dir = NewScratchDirectory(); + var refAsmDir = NewScratchDirectory(); + var refAsm = Path.Combine(refAsmDir, "Renamed.Compile.Asset.dll"); + CreateReferenceAssemblyCopy(TargetLibPath, refAsm); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, "Renamed.Compile.Asset"); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(refAsm)]; + task.RuntimeAssemblies = [new TaskItem(TargetLibPath)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(0); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(refAsm); + + // The publicized bits must come from the implementation — the ref-assembly copy would + // still carry ReferenceAssemblyAttribute. + var publicized = task.PublicizedReferences[0].ItemSpec; + using var module = Mono.Cecil.ModuleDefinition.ReadModule(publicized); + var isRefAssembly = module.Assembly.CustomAttributes.Any(a => + a.AttributeType.FullName == "System.Runtime.CompilerServices.ReferenceAssemblyAttribute"); + await Assert.That(isRefAssembly).IsFalse(); + } + + [Test] + public async Task Same_File_Name_Identity_Pair_Resolves_Each_Implementation_By_Identity() + { + // Two metadata-only references sharing a file name but holding DISTINCT identities + // (extern-alias pair) both search the runtime assets — a filename-first shortcut would + // hand both groups the first implementation, so the second publicized copy would carry + // the first assembly's identity. + var dir = NewScratchDirectory(); + + var implOldDir = NewScratchDirectory(); + var implOld = Path.Combine(implOldDir, TargetLibName + ".dll"); + CreateDifferentVersionCopy(TargetLibPath, implOld); + + var refNewDir = NewScratchDirectory(); + var refNew = Path.Combine(refNewDir, TargetLibName + ".dll"); + CreateReferenceAssemblyCopy(TargetLibPath, refNew); + var refOldDir = NewScratchDirectory(); + var refOld = Path.Combine(refOldDir, TargetLibName + ".dll"); + CreateReferenceAssemblyCopy(implOld, refOld); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(refNew), new TaskItem(refOld)]; + task.RuntimeAssemblies = [new TaskItem(TargetLibPath), new TaskItem(implOld)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2); + + // Each copy must hold ITS reference's identity, resolved from the matching + // implementation — never the other identity's asset. + var originalVersion = AssemblyName.GetAssemblyName(TargetLibPath).Version; + await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).Version) + .IsEqualTo(originalVersion); + await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[1].ItemSpec).Version) + .IsEqualTo(new Version(99, 0, 0, 0)); + } + + [Test] + public async Task Colliding_File_Name_Does_Not_Hide_The_Identity_Match() + { + // One reference carries the requested FILE name but a different assembly identity, while + // a renamed reference holds the requested identity. The documented contract is the simple + // assembly name, so the identity match must win — a filename-first lookup would publicize + // the wrong assembly and leave the intended reference untouched. + var dir = NewScratchDirectory(); + + var decoyDir = NewScratchDirectory(); + var decoy = Path.Combine(decoyDir, TargetLibName + ".dll"); + CreateRenamedIdentityCopy(TargetLibPath, decoy, "Some.Unrelated.Assembly"); + + var aliasDir = NewScratchDirectory(); + var alias = Path.Combine(aliasDir, "VendorAlias.dll"); + File.Copy(TargetLibPath, alias); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(decoy), new TaskItem(alias)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(task.PublicizedReferences.Length).IsEqualTo(1); + await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(alias); + await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).Name) + .IsEqualTo(TargetLibName); + } + + [Test] + public async Task Runtime_Asset_With_Conflicting_Public_Key_Token_Is_Never_Selected() + { + // Same simple name and version but a different public key token is a DIFFERENT assembly; + // handing it back as the "implementation" would swap identities. The stripped ref + // assembly must be publicized instead, with the TUMIA003 warning. + var dir = NewScratchDirectory(); + + var refAsmDir = NewScratchDirectory(); + var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll"); + CreateReferenceAssemblyCopy(TargetLibPath, refAsm); + + var unsignedDir = NewScratchDirectory(); + var unsignedImpl = Path.Combine(unsignedDir, TargetLibName + ".dll"); + CreateUnsignedCopy(TargetLibPath, unsignedImpl); + + // Sanity: the scenario only exists if the tokens actually differ. + await Assert.That(AssemblyName.GetAssemblyName(TargetLibPath).GetPublicKeyToken()!.Length) + .IsNotEqualTo(0); + await Assert.That(AssemblyName.GetAssemblyName(unsignedImpl).GetPublicKeyToken() ?? []) + .IsEmpty(); + + var engine = new StubBuildEngine(); + var task = CreateTask(dir, TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(refAsm)]; + task.RuntimeAssemblies = [new TaskItem(unsignedImpl)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Count).IsEqualTo(1); + await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA003"); + + // Publicized from the ref assembly itself — it keeps ITS identity. + var publicizedToken = AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).GetPublicKeyToken(); + await Assert.That(publicizedToken).IsEquivalentTo(AssemblyName.GetAssemblyName(TargetLibPath).GetPublicKeyToken()!); + } + + private static void CreateRenamedIdentityCopy(string source, string destination, string newAssemblyName) + { + using var module = Mono.Cecil.ModuleDefinition.ReadModule(source); + module.Assembly.Name.Name = newAssemblyName; + module.Write(destination); + } + + private static void CreateUnsignedCopy(string source, string destination) + { + using var module = Mono.Cecil.ModuleDefinition.ReadModule(source); + module.Assembly.Name.PublicKey = []; + module.Assembly.Name.HasPublicKey = false; + module.Write(destination); + } + + private static void CreateReferenceAssemblyCopy(string source, string destination) + { + using var module = Mono.Cecil.ModuleDefinition.ReadModule(source); + var attributeType = new Mono.Cecil.TypeReference( + "System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", + module, module.TypeSystem.CoreLibrary); + var constructor = new Mono.Cecil.MethodReference(".ctor", module.TypeSystem.Void, attributeType) + { + HasThis = true, + }; + module.Assembly.CustomAttributes.Add(new Mono.Cecil.CustomAttribute(constructor)); + module.Write(destination); + } + + [Test] + public async Task Unresolved_Assembly_Name_Fails_With_TUMIA001() + { + var dir = NewScratchDirectory(); + var engine = new StubBuildEngine(); + var task = CreateTask(dir, "No.Such.Assembly"); + task.BuildEngine = engine; + + await Assert.That(task.Execute()).IsFalse(); + await Assert.That(engine.Errors.Count).IsEqualTo(1); + await Assert.That(engine.Errors[0].Code).IsEqualTo("TUMIA001"); + } + + private sealed class StubBuildEngine : IBuildEngine + { + public List Errors { get; } = []; + + public List Warnings { get; } = []; + + public bool ContinueOnError => false; + + public int LineNumberOfTaskNode => 0; + + public int ColumnNumberOfTaskNode => 0; + + public string ProjectFileOfTaskNode => ""; + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => false; + + public void LogCustomEvent(CustomBuildEventArgs e) + { + } + + public void LogErrorEvent(BuildErrorEventArgs e) => Errors.Add(e); + + public void LogMessageEvent(BuildMessageEventArgs e) + { + } + + public void LogWarningEvent(BuildWarningEventArgs e) => Warnings.Add(e); + } +} diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj new file mode 100644 index 0000000000..93923485c6 --- /dev/null +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj @@ -0,0 +1,39 @@ + + + + + + net10.0 + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs b/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs new file mode 100644 index 0000000000..cee8287418 --- /dev/null +++ b/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs @@ -0,0 +1,33 @@ +using ModularPipelines.Context; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Options; +using TUnit.Pipeline.Modules.Abstract; + +namespace TUnit.Pipeline.Modules; + +public class RunMockInternalsAccessTestsModule : TestBaseModule +{ + protected override Task<(DotNetRunOptions Options, CommandExecutionOptions? ExecutionOptions)> GetTestOptions(IModuleContext context, string framework, CancellationToken cancellationToken) + { + var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.Mocks.InternalsAccess.Tests.csproj").AssertExists(); + + return Task.FromResult<(DotNetRunOptions, CommandExecutionOptions?)>(( + new DotNetRunOptions + { + NoBuild = true, + Configuration = "Release", + Framework = framework, + }, + new CommandExecutionOptions + { + WorkingDirectory = project.Folder!.Path, + EnvironmentVariables = new Dictionary + { + ["DISABLE_GITHUB_REPORTER"] = "true", + } + } + )); + } +}