Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions eng/TestLibrary.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
<Project>
<Import Project="$(MSBuildThisFileDirectory)..\src\TUnit.Core\TUnit.Core.props" />

<!--
TUnit.Core.props adds TUnit.Core.GeneratedNamespace.cs so the compiler emits the source-generated
TUnit.Generated types after the project's own types (see that file). From a NuGet package the props is
imported before the SDK's default Compile glob, so the file is already first. Repo projects import this
file from their project body, after the glob, so the file lands last and has no effect. Move it to the
front just before compiling. This only reorders the Compile items passed to Csc, so evaluation (and
Visual Studio's view of globbed items) is unchanged.
-->
<Target Name="_TUnitCompileGeneratedNamespaceFirst" BeforeTargets="CoreCompile" Condition="'$(Language)' == 'C#'">
<ItemGroup>
<_TUnitGeneratedNamespaceCompile Include="@(Compile)" Condition="'%(Filename)%(Extension)' == 'TUnit.Core.GeneratedNamespace.cs'" />
<_TUnitOtherCompile Include="@(Compile)" Exclude="@(_TUnitGeneratedNamespaceCompile)" Condition="'@(_TUnitGeneratedNamespaceCompile)' != ''" />
<Compile Remove="@(Compile)" Condition="'@(_TUnitGeneratedNamespaceCompile)' != ''" />
<Compile Include="@(_TUnitGeneratedNamespaceCompile);@(_TUnitOtherCompile)" Condition="'@(_TUnitGeneratedNamespaceCompile)' != ''" />
</ItemGroup>
</Target>

<PropertyGroup>
<OutputType>Library</OutputType>

Expand Down
13 changes: 13 additions & 0 deletions src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// <auto-generated/>
// Added to test projects by TUnit.Core.props so it compiles ahead of the project's own files.
//
// The C# compiler writes namespaces to the assembly in reverse order of their first declaration.
// Source-generated trees are always added after the project's files, so without this declaration
// the TUnit.Generated types (large static constructors registering every test) are written at the
// very start of the assembly. Antivirus scanners such as Microsoft Defender analyse code at the
// start of an assembly far more expensively, which added seconds to every build and first test run
// of large test projects on Windows. Declaring the namespace first moves the generated types to the
// end of the assembly instead.
namespace TUnit.Generated
{
}
12 changes: 12 additions & 0 deletions src/TUnit.Core/TUnit.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@
<Pack>true</Pack>
<PackagePath>$(BuildPath)</PackagePath>
</None>

<!-- Compiled into consuming test projects by TUnit.Core.props, never into TUnit.Core itself. -->
<Compile Remove="TUnit.Core.GeneratedNamespace.cs" />
<TfmSpecificPackageFile Include="TUnit.Core.GeneratedNamespace.cs">
<Pack>true</Pack>
<PackagePath>$(BuildTransitivePath)</PackagePath>
</TfmSpecificPackageFile>

<None Include="TUnit.Core.GeneratedNamespace.cs">
<Pack>true</Pack>
<PackagePath>$(BuildPath)</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<None
Expand Down
14 changes: 14 additions & 0 deletions src/TUnit.Core/TUnit.Core.props
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,18 @@
<CompilerVisibleProperty Include="EnableTUnitSourceGeneration" />
</ItemGroup>

<!--
Declares the TUnit.Generated namespace before the project's own files (items added here precede the SDK's
default Compile glob) so the compiler writes the source-generated types at the end of the assembly rather
than the start, where Microsoft Defender scans them far more slowly. See TUnit.Core.GeneratedNamespace.cs.

This must stay in the .props: items added from a .targets land after the default glob. The condition is safe
here even though Language is set later by the C# targets and EnableTUnitSourceGeneration may be set in the
project body, because MSBuild evaluates every property before any item, so item conditions see final values.
Guarded by TUnit.NugetTester's GeneratedTypeOrderTests.
-->
<ItemGroup Condition="'$(Language)' == 'C#' and '$(EnableTUnitSourceGeneration)' != 'false'">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Compile Include="$(MSBuildThisFileDirectory)TUnit.Core.GeneratedNamespace.cs" Visible="false" />
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace TUnit.NugetTester;

/// <summary>
/// TUnit.Core.props adds TUnit.Core.GeneratedNamespace.cs ahead of the project's own files so the compiler
/// emits the source-generated TUnit.Generated types after the user's types. Emitting them first made
/// Microsoft Defender scan large test assemblies for seconds on every build (see PR #6908).
/// This project consumes the packed packages, so it guards the real NuGet import path.
/// </summary>
public class GeneratedTypeOrderTests
{
[Test]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Only inspects this assembly's own type definitions.")]
public async Task Generated_Types_Are_Emitted_After_User_Types()
{
var types = typeof(GeneratedTypeOrderTests).Assembly.GetTypes();

var generatedTypes = types.Where(t => t.Namespace == "TUnit.Generated").ToArray();
var userTypes = types.Where(t => !t.IsNested && t.Namespace == typeof(GeneratedTypeOrderTests).Namespace).ToArray();

await Assert.That(generatedTypes).IsNotEmpty();
await Assert.That(userTypes).IsNotEmpty();

// TypeDef metadata tokens follow the order the compiler emitted the types in.
var firstGenerated = generatedTypes.Min(t => t.MetadataToken);
var lastUser = userTypes.Max(t => t.MetadataToken);

await Assert.That(firstGenerated).IsGreaterThan(lastUser);
}
}
Loading