diff --git a/src/Tools/Extensions.ApiDescription.Client/src/build/Microsoft.Extensions.ApiDescription.Client.targets b/src/Tools/Extensions.ApiDescription.Client/src/build/Microsoft.Extensions.ApiDescription.Client.targets
index af590565c61990cbf500a323c1901796874370a7..f3640745069e06da42733bb4ae5c9384aed495f7 100644
--- a/src/Tools/Extensions.ApiDescription.Client/src/build/Microsoft.Extensions.ApiDescription.Client.targets
+++ b/src/Tools/Extensions.ApiDescription.Client/src/build/Microsoft.Extensions.ApiDescription.Client.targets
@@ -84,6 +84,7 @@
 
   <Target Name="_InnerGenerateOpenApiCode" DependsOnTargets="_GetCurrentOpenApiReference;$(GeneratorTarget)" />
 
+  <!-- Note target will **always** execute when generator uses the OutputPath as a directory. -->
   <Target Name="_GenerateOpenApiCode"
       Condition="$(OpenApiGenerateCodeAtDesignTime) OR ('$(DesignTimeBuild)' != 'true' AND '$(BuildingProject)' == 'true')"
       Inputs="@(OpenApiReference)"
@@ -100,44 +101,57 @@
     <ItemGroup>
       <_Files Remove="@(_Files)" />
       <_Files Include="@(OpenApiReference -> '%(OutputPath)')"
-          Condition="$([System.IO.File]::Exists('%(OpenApiReference.OutputPath)'))">
-        <OutputPathExtension>$([System.IO.Path]::GetExtension('%(OpenApiReference.OutputPath)'))</OutputPathExtension>
-      </_Files>
+          Condition="$([System.IO.File]::Exists('%(OpenApiReference.OutputPath)'))" />
       <_Directories Remove="@(_Directories)" />
       <_Directories Include="@(OpenApiReference -> '%(OutputPath)')"
           Condition="Exists('%(OpenApiReference.OutputPath)') AND ! $([System.IO.File]::Exists('%(OpenApiReference.OutputPath)'))" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <_Files SourceDocument="%(OriginalItemSpec)" RemoveMetadata="OriginalItemSpec" />
+      <_Directories SourceDocument="%(OriginalItemSpec)" RemoveMetadata="OriginalItemSpec" />
+    </ItemGroup>
 
+    <ItemGroup>
       <!-- If OutputPath is a file, add it directly to relevant items. -->
       <TypeScriptCompile Include="@(_Files)"
           Exclude="@(TypeScriptCompile)"
-          Condition="'%(_Files.OutputPathExtension)' == '.ts' OR '%(_Files.OutputPathExtension)' == '.tsx'">
-        <SourceDocument>%(_Files.FullPath)</SourceDocument>
-      </TypeScriptCompile>
+          Condition="'%(_Files.Extension)' == '.ts' OR '%(_Files.Extension)' == '.tsx'" />
 
       <Compile Include="@(_Files)"
           Exclude="@(Compile)"
-          Condition="'$(DefaultLanguageSourceExtension)' != '.ts' AND '%(_Files.OutputPathExtension)' == '$(DefaultLanguageSourceExtension)'">
-        <SourceDocument>%(OpenApiReference.FullPath)</SourceDocument>
-      </Compile>
+          Condition="'$(DefaultLanguageSourceExtension)' != '.ts' AND '%(_Files.Extension)' == '$(DefaultLanguageSourceExtension)'" />
+    </ItemGroup>
 
-      <!-- Otherwise, add all descendant files with the expected extension. -->
-      <TypeScriptCompile Include="@(_Directories -> '%(Identity)/**/*.ts;%(Identity)/**/*.tsx')"
-          Exclude="@(TypeScriptCompile)">
-        <SourceDocument>%(_Directories.FullPath)</SourceDocument>
-      </TypeScriptCompile>
+    <!--
+      Otherwise, add all descendant files with the expected extension. Glob into _Directories before updating
+      TypeScriptCompile or Compile items. See <https://github.com/dotnet/msbuild/issues/3274> and workaround in
+      <https://stackoverflow.com/questions/48868060/can-a-task-itemgroup-glob-files>. Unfortunately, this workaround
+      loses SourceDocument and other metadata.
+    -->
+    <PropertyGroup>
+      <_TypeScriptCompileItemsFromDirectories>@(_Directories -> '%(Identity)/**/*.ts;%(Identity)/**/*.tsx')</_TypeScriptCompileItemsFromDirectories>
+      <_CompileItemsFromDirectories>@(_Directories -> '%(Identity)/**/*$(DefaultLanguageSourceExtension)')</_CompileItemsFromDirectories>
+    </PropertyGroup>
 
-      <Compile Include="@(_Directories -> '%(Identity)/**/*.$(DefaultLanguageSourceExtension)')"
+    <ItemGroup>
+      <TypeScriptCompile Include="$(_TypeScriptCompileItemsFromDirectories)" Exclude="@(TypeScriptCompile)" />
+
+      <Compile Include="$(_CompileItemsFromDirectories)"
           Exclude="@(Compile)"
-          Condition="'$(DefaultLanguageSourceExtension)' != '.ts'">
-        <SourceDocument>%(_Directories.FullPath)</SourceDocument>
-      </Compile>
+          Condition="'$(DefaultLanguageSourceExtension)' != '.ts'" />
 
-      <FileWrites Exclude="@(FileWrites)"
-          Include="@(_Files);@(_Directories -> '%(Identity)/**/*.ts;%(Identity)/**/*.tsx;%(Identity)/**/*.$(DefaultLanguageSourceExtension)')" />
+      <FileWrites Include="@(_Files);$(_TypeScriptCompileItemsFromDirectories);$(_CompileItemsFromDirectories)"
+          Exclude="@(FileWrites)" />
 
       <_Files Remove="@(_Files)" />
       <_Directories Remove="@(_Directories)" />
     </ItemGroup>
+
+    <PropertyGroup>
+      <_TypeScriptCompileItemsFromDirectories />
+      <_CompileItemsFromDirectories />
+    </PropertyGroup>
   </Target>
 
   <!-- Inform users of breaking changes in this file and Microsoft.Extensions.ApiDescription.Client.props. -->
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/Microsoft.Extensions.ApiDescription.Client.Tests.csproj b/src/Tools/Extensions.ApiDescription.Client/test/Microsoft.Extensions.ApiDescription.Client.Tests.csproj
index db09c31bee67b519664aeb4f57697e406ccd75f5..1ccce0e63658bd39ce23be067f8de602fe8f05ec 100644
--- a/src/Tools/Extensions.ApiDescription.Client/test/Microsoft.Extensions.ApiDescription.Client.Tests.csproj
+++ b/src/Tools/Extensions.ApiDescription.Client/test/Microsoft.Extensions.ApiDescription.Client.Tests.csproj
@@ -1,5 +1,4 @@
 <Project Sdk="Microsoft.NET.Sdk">
-
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <DefaultItemExcludes>$(DefaultItemExcludes);TestProjects\**\*</DefaultItemExcludes>
@@ -7,15 +6,20 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <Content Include="$(MSBuildThisFileDirectory)..\src\build\**\*" CopyToOutputDirectory="PreserveNewest" LinkBase="build" />
-    <Content Include="$(MSBuildThisFileDirectory)..\src\buildMultiTargeting\**\*" CopyToOutputDirectory="PreserveNewest" LinkBase="buildMultiTargeting" />
-    <Content Include="TestProjects\**\*" CopyToOutputDirectory="PreserveNewest" />
+    <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
+      <_Parameter1>TargetFramework</_Parameter1>
+      <_Parameter2>$(TargetFramework)</_Parameter2>
+    </AssemblyAttribute>
 
-    <Reference Include="Microsoft.Extensions.ApiDescription.Client" />
-  </ItemGroup>
+    <Compile Include="$(SharedSourceRoot)Process\*.cs" LinkBase="ProcessHelpers" />
+    <Compile Include="$(SharedSourceRoot)\CommandLineUtils\Utilities\DotNetMuxer.cs"
+        Link="ProcessHelpers\DotNetMuxer.cs" />
+    <Compile Include="$(ToolSharedSourceRoot)TestHelpers\Temporary*.cs" LinkBase="TestHelpers" />
 
-  <Target Name="CleanTestProjects" BeforeTargets="CoreCompile">
-    <RemoveDir Directories="$(TargetDir)TestProjects" />
-  </Target>
+    <Content Include="..\src\build\**\*" LinkBase="build" />
+    <Content Include="..\src\buildMultiTargeting\**\*" LinkBase="buildMultiTargeting" />
+    <Content Include="TestProjects\**\*" />
 
+    <Reference Include="Microsoft.Extensions.ApiDescription.Client" />
+  </ItemGroup>
 </Project>
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/TargetTest.cs b/src/Tools/Extensions.ApiDescription.Client/test/TargetTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..4de0605a76f5d0943fe6b0d7ffa8a6122107a80b
--- /dev/null
+++ b/src/Tools/Extensions.ApiDescription.Client/test/TargetTest.cs
@@ -0,0 +1,503 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+using Xunit.Abstractions;
+
+namespace Microsoft.Extensions.ApiDescription.Client;
+
+public class TargetTest : IDisposable
+{
+    private static Assembly _assembly = typeof(TargetTest).Assembly;
+    private static string _assemblyLocation = Path.GetDirectoryName(_assembly.Location);
+    private static string _targetFramework = _assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
+        .Single(m => m.Key == "TargetFramework")
+        .Value;
+
+    private ITestOutputHelper _output;
+    private TemporaryDirectory _temporaryDirectory;
+
+    public TargetTest(ITestOutputHelper output)
+    {
+        _output = output;
+        _temporaryDirectory = new TemporaryDirectory();
+
+        var build = _temporaryDirectory.SubDir("build");
+        var files = _temporaryDirectory.SubDir("files");
+        var tasks = _temporaryDirectory.SubDir("tasks").SubDir("netstandard2.0");
+        _temporaryDirectory.Create();
+
+        // Populate temporary build folder.
+        var directory = new DirectoryInfo(Path.Combine(_assemblyLocation, "build"));
+        foreach (var file in directory.GetFiles())
+        {
+            file.CopyTo(Path.Combine(build.Root, file.Name), overwrite: true);
+        }
+        directory = new DirectoryInfo(Path.Combine(_assemblyLocation, "TestProjects", "build"));
+        foreach (var file in directory.GetFiles())
+        {
+            file.CopyTo(Path.Combine(build.Root, file.Name), overwrite: true);
+        }
+
+        // Populate temporary files folder.
+        directory = new DirectoryInfo(Path.Combine(_assemblyLocation, "TestProjects", "files"));
+        foreach (var file in directory.GetFiles())
+        {
+            file.CopyTo(Path.Combine(files.Root, file.Name), overwrite: true);
+        }
+
+        // Populate temporary tasks folder.
+        directory = new DirectoryInfo(_assemblyLocation);
+        foreach (var file in directory.GetFiles("Microsoft.Extensions.ApiDescription.Client.???"))
+        {
+            file.CopyTo(Path.Combine(tasks.Root, file.Name), overwrite: true);
+        }
+    }
+
+    [Fact]
+    public async Task AddsExpectedItems()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains($"Compile: {Path.Combine("obj", "azureMonitorClient.cs")}", process.Output);
+        Assert.Contains($"FileWrites: {Path.Combine("obj", "azureMonitorClient.cs")}", process.Output);
+        Assert.DoesNotContain("TypeScriptCompile:", process.Output);
+    }
+
+    [Fact]
+    public async Task AddsExpectedItems_WithCodeGenerator()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                CodeGenerator = "NSwagTypeScript",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.DoesNotContain(" Compile:", process.Output);
+        Assert.Contains($"FileWrites: {Path.Combine("obj", "azureMonitorClient.ts")}", process.Output);
+        Assert.Contains($"TypeScriptCompile: {Path.Combine("obj", "azureMonitorClient.ts")}", process.Output);
+    }
+
+    [Fact]
+    public async Task AddsExpectedItems_WithMultipleFiles()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json;files/NSwag.json;files/swashbuckle.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains($"Compile: {Path.Combine("obj", "azureMonitorClient.cs")}", process.Output);
+        Assert.Contains($"Compile: {Path.Combine("obj", "NSwagClient.cs")}", process.Output);
+        Assert.Contains($"Compile: {Path.Combine("obj", "swashbuckleClient.cs")}", process.Output);
+        Assert.Contains($"FileWrites: {Path.Combine("obj", "azureMonitorClient.cs")}", process.Output);
+        Assert.Contains($"FileWrites: {Path.Combine("obj", "NSwagClient.cs")}", process.Output);
+        Assert.Contains($"FileWrites: {Path.Combine("obj", "swashbuckleClient.cs")}", process.Output);
+        Assert.DoesNotContain("TypeScriptCompile:", process.Output);
+    }
+
+    [Fact]
+    public async Task AddsExpectedItems_WithMultipleFilesFromGenerator()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                CodeGenerator = "CustomCSharp",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains($"Compile: {Path.Combine("obj", "azureMonitorClient.cs", "Generated1.cs")}", process.Output);
+        Assert.Contains($"Compile: {Path.Combine("obj", "azureMonitorClient.cs", "Generated2.cs")}", process.Output);
+        Assert.Contains(
+            $"FileWrites: {Path.Combine("obj", "azureMonitorClient.cs", "Generated1.cs")}",
+            process.Output);
+        Assert.Contains(
+            $"FileWrites: {Path.Combine("obj", "azureMonitorClient.cs", "Generated2.cs")}",
+            process.Output);
+        Assert.DoesNotContain("TypeScriptCompile:", process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithOpenApiGenerateCodeOptions()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithProperty("OpenApiGenerateCodeOptions", "--an-option")
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '--an-option' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithOpenApiCodeDirectory()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithProperty("OpenApiCodeDirectory", "generated")
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("generated", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithClassName()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                ClassName = "AzureMonitor"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+
+        // Note ClassName does **not** override OutputPath.
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.AzureMonitor' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithCodeGenerator()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec {
+                Include = "files/azureMonitor.json",
+                CodeGenerator="NSwagTypeScript"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagTypeScript " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.ts")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithNamespace()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                Namespace = "SomeNamespace"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'SomeNamespace.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithOptions()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec {
+                Include = "files/azureMonitor.json",
+                Options="--an-option"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '--an-option' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithOutputPath()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                OutputPath = "Custom.cs"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+
+        // Note OutputPath also overrides ClassName.
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.Custom' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "Custom.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithMultipleFiles()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json;files/NSwag.json;files/swashbuckle.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "NSwag.json")} " +
+            "Class: 'test.NSwagClient' FirstForGenerator: 'false' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "NSwagClient.cs")}'",
+            process.Output);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "swashbuckle.json")} " +
+            "Class: 'test.swashbuckleClient' FirstForGenerator: 'false' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "swashbuckleClient.cs")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task ExecutesGeneratorTarget_WithMultipleGenerators()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            })
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+                CodeGenerator = "NSwagTypeScript"
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.Contains(
+            "GenerateNSwagCSharp " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.cs")}'",
+            process.Output);
+        Assert.Contains(
+            "GenerateNSwagTypeScript " +
+            $"{Path.Combine(_temporaryDirectory.Root, "files", "azureMonitor.json")} " +
+            "Class: 'test.azureMonitorClient' FirstForGenerator: 'true' " +
+            $"Options: '' OutputPath: '{Path.Combine("obj", "azureMonitorClient.ts")}'",
+            process.Output);
+    }
+
+    [Fact]
+    public async Task SkipsGeneratorTarget_InSubsequentBuilds()
+    {
+        // Arrange
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithProperty("OpenApiGenerateCodeOnBuild", "false")
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        // Act 1
+        using var firstProcess = await RunBuild();
+
+        // Assert 1 aka Guards
+        Assert.Equal(0, firstProcess.ExitCode);
+        Assert.Empty(firstProcess.Error);
+
+        // Act 2
+        using var secondProcess = await RunBuild();
+
+        // Assert 2
+        Assert.Equal(0, secondProcess.ExitCode);
+        Assert.Empty(secondProcess.Error);
+        Assert.DoesNotContain("GenerateNSwagCSharp ", secondProcess.Output);
+
+        // Act 3
+        using var thirdProcess = await RunBuild();
+
+        // Assert 2
+        Assert.Equal(0, thirdProcess.ExitCode);
+        Assert.Empty(thirdProcess.Error);
+        Assert.DoesNotContain("GenerateNSwagCSharp ", thirdProcess.Output);
+    }
+
+    [Fact]
+    public async Task SkipsGeneratorTarget_WithOpenApiGenerateCodeOnBuild()
+    {
+        var project = new TemporaryOpenApiProject("test", _temporaryDirectory, "Microsoft.NET.Sdk")
+            .WithTargetFrameworks(_targetFramework)
+            .WithProperty("OpenApiGenerateCodeOnBuild", "false")
+            .WithItem(new TemporaryOpenApiProject.ItemSpec
+            {
+                Include = "files/azureMonitor.json",
+            });
+        _temporaryDirectory.WithCSharpProject(project);
+        project.Create();
+
+        using var process = await RunBuild();
+
+        Assert.Equal(0, process.ExitCode);
+        Assert.Empty(process.Error);
+        Assert.DoesNotContain("GenerateNSwagCSharp ", process.Output);
+    }
+
+    public void Dispose()
+    {
+        _temporaryDirectory.Dispose();
+    }
+
+    private async Task<ProcessEx> RunBuild()
+    {
+        var process = ProcessEx.Run(
+            _output,
+            _temporaryDirectory.Root,
+            DotNetMuxer.MuxerPathOrDefault(),
+            "build");
+        await process.Exited;
+
+        return process;
+    }
+}
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/TemporaryOpenApiProject.cs b/src/Tools/Extensions.ApiDescription.Client/test/TemporaryOpenApiProject.cs
new file mode 100644
index 0000000000000000000000000000000000000000..5e6739779928b6f8f343c919e454ee1a2f34498d
--- /dev/null
+++ b/src/Tools/Extensions.ApiDescription.Client/test/TemporaryOpenApiProject.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+
+namespace Microsoft.Extensions.Tools.Internal;
+
+public class TemporaryOpenApiProject : TemporaryCSharpProject
+{
+    public TemporaryOpenApiProject(string name, TemporaryDirectory directory, string sdk)
+        : base(name, directory, sdk)
+    {
+    }
+
+    protected override string Template =>
+@"<Project Sdk=""{2}"">
+  <Import Project=""build/Microsoft.Extensions.ApiDescription.Client.props"" />
+
+  <PropertyGroup>
+    {0}
+  </PropertyGroup>
+  <ItemGroup>
+    {1}
+  </ItemGroup>
+
+  <!-- Check _CreateCompileItemsForOpenApiReferences output items. -->
+  <Target Name=""_WriteWrites"" AfterTargets=""_CreateCompileItemsForOpenApiReferences"">
+    <Message Importance=""high""
+        Text=""Compile: %(Compile.Identity)""
+        Condition="" '@(Compile)' != '' "" />
+    <Message Importance=""high"" Text=""FileWrites: %(FileWrites.Identity)"" />
+    <Message Importance=""high""
+        Text=""TypeScriptCompile: %(TypeScriptCompile.Identity)""
+        Condition="" '@(TypeScriptCompile)' != '' "" />
+  </Target>
+
+  <Import Project=""build/Microsoft.Extensions.ApiDescription.Client.targets"" />
+  <Import Project=""build/Fakes.targets"" />
+</Project>";
+
+    protected override void AddAdditionalAttributes(StringBuilder sb, TemporaryCSharpProject.ItemSpec item)
+    {
+        var openApiItem = item as ItemSpec;
+        if (openApiItem.ClassName != null)
+        {
+            sb.Append(" ClassName=\"").Append(openApiItem.ClassName).Append('"');
+        }
+
+        if (openApiItem.CodeGenerator != null)
+        {
+            sb.Append(" CodeGenerator=\"").Append(openApiItem.CodeGenerator).Append('"');
+        }
+
+        if (openApiItem.Namespace != null)
+        {
+            sb.Append(" Namespace=\"").Append(openApiItem.Namespace).Append('"');
+        }
+
+        if (openApiItem.Options != null)
+        {
+            sb.Append(" Options=\"").Append(openApiItem.Options).Append('"');
+        }
+
+        if (openApiItem.OutputPath != null)
+        {
+            sb.Append(" OutputPath=\"").Append(openApiItem.OutputPath).Append('"');
+        }
+    }
+
+    public new class ItemSpec : TemporaryCSharpProject.ItemSpec
+    {
+        public ItemSpec() : base()
+        {
+            Name = "OpenApiReference";
+        }
+
+        // Metadata specific to OpenApiReference items.
+        public string ClassName { get; set; }
+
+        public string CodeGenerator { get; set; }
+
+        public string Namespace { get; set; }
+
+        public string Options { get; set; }
+
+        public string OutputPath { get; set; }
+    }
+}
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/ConsoleClient.csproj b/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/ConsoleClient.csproj
deleted file mode 100644
index 18c332c188eb0575c3678f6bbe25396b8f3ea707..0000000000000000000000000000000000000000
--- a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/ConsoleClient.csproj
+++ /dev/null
@@ -1,38 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-  <Import Project="../../build/Microsoft.Extensions.ApiDescription.Client.props"
-      Condition="Exists('../../build/Microsoft.Extensions.ApiDescription.Client.props')" />
-
-  <PropertyGroup>
-    <OutputType>Exe</OutputType>
-    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
-    <IsTestAssetProject>true</IsTestAssetProject>
-  </PropertyGroup>
-
-  <ItemGroup>
-    <OpenApiReference Include="../files/azureMonitor.json" />
-    <OpenApiReference Include="../files/NSwag.json" />
-    <OpenApiReference Include="../files/swashbuckle.json" />
-  </ItemGroup>
-
-  <!-- Position tasks assembly where Microsoft.Extensions.ApiDescription.Client.props expects it. -->
-  <Target Name="CopyTargets" BeforeTargets="BeforeBuild">
-    <ItemGroup>
-      <_Files Include="../../Microsoft.Extensions.ApiDescription.Client.*"
-          Exclude="../../Microsoft.Extensions.ApiDescription.Client.Tests.*" />
-    </ItemGroup>
-
-    <Copy
-        SourceFiles="@(_Files)"
-        DestinationFolder="../../tasks/netstandard2.0/"
-        SkipUnchangedFiles="$(SkipCopyUnchangedFiles)"
-        OverwriteReadOnlyFiles="$(OverwriteReadOnlyFiles)"
-        Retries="$(CopyRetryCount)"
-        RetryDelayMilliseconds="$(CopyRetryDelayMilliseconds)"
-        UseHardlinksIfPossible="$(CreateHardLinksForCopyFilesToOutputDirectoryIfPossible)"
-        UseSymboliclinksIfPossible="$(CreateSymbolicLinksForCopyFilesToOutputDirectoryIfPossible)" />
-  </Target>
-
-  <Import Project="../../build/Microsoft.Extensions.ApiDescription.Client.targets"
-      Condition="Exists('../../build/Microsoft.Extensions.ApiDescription.Client.targets')" />
-  <Import Project="../build/Fakes.targets" Condition="EXISTS('../build/Fakes.targets')" />
-</Project>
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/Program.cs b/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/Program.cs
deleted file mode 100644
index 35f6c0593ed4618cebb4aea16e806b0ade30e8ef..0000000000000000000000000000000000000000
--- a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/ConsoleClient/Program.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System;
-
-namespace ConsoleClient;
-
-class Program
-{
-    static void Main(string[] args)
-    {
-        Console.WriteLine("Hello World!");
-    }
-}
diff --git a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/build/Fakes.targets b/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/build/Fakes.targets
index 78597f2ac60d97aa2d2209ada98bc42cbf525f02..480fb5b573165d9a36d74c0c03c1cdfcbcadfd13 100644
--- a/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/build/Fakes.targets
+++ b/src/Tools/Extensions.ApiDescription.Client/test/TestProjects/build/Fakes.targets
@@ -1,40 +1,52 @@
-<?xml version="1.0" encoding="utf-8" standalone="no"?>
+<?xml version="1.0" encoding="utf-8" standalone="no"?>
 <Project>
-  <!-- NSwag's OpenApiReference support for C# (default target). -->
-
-  <Target Name="GenerateNSwagCSharp">
+  <Target Name="_GetMetadataProperties">
+    <ItemGroup>
+      <_HeaderMetadata Include="Licensed to the .NET Foundation under one or more agreements." />
+      <_HeaderMetadata Include="The .NET Foundation licenses this file to you under the MIT license." />
+
+      <_Metadata Include="__TargetName__" />
+      <_Metadata Include="%(CurrentOpenApiReference.FullPath)" />
+      <_Metadata Include="Class: '%(CurrentOpenApiReference.Namespace).%(CurrentOpenApiReference.ClassName)'" />
+      <_Metadata Include="FirstForGenerator: '%(CurrentOpenApiReference.FirstForGenerator)'" />
+      <_Metadata Include="Options: '%(CurrentOpenApiReference.Options)'" />
+      <_Metadata Include="OutputPath: '%(CurrentOpenApiReference.OutputPath)'" />
+    </ItemGroup>
     <PropertyGroup>
-      <_Metadata>'%(CurrentOpenApiReference.FullPath)'</_Metadata>
-      <_Metadata>$(_Metadata) Class: '%(CurrentOpenApiReference.Namespace).%(CurrentOpenApiReference.ClassName)'</_Metadata>
-      <_Metadata>$(_Metadata) FirstForGenerator: '%(CurrentOpenApiReference.FirstForGenerator)'</_Metadata>
-      <_Metadata>$(_Metadata) Options: '%(CurrentOpenApiReference.Options)'</_Metadata>
-      <_Metadata>$(_Metadata) OutputPath: '%(CurrentOpenApiReference.OutputPath)'</_Metadata>
+      <_Message>@(_Metadata, ' ')</_Message>
+      <_Lines>@(_HeaderMetadata -> '// %(Identity)', '%0A')
+
+</_Lines>
     </PropertyGroup>
+  </Target>
+
+  <!-- NSwag's OpenApiReference support for C# (default target). -->
 
-    <Message Importance="high" Text="GenerateNSwagCSharp $(_Metadata)" />
+  <Target Name="GenerateNSwagCSharp" DependsOnTargets="_GetMetadataProperties">
+    <Message Importance="high" Text="$(_Message.Replace('__TargetName__', 'GenerateNSwagCSharp'))" />
+    <WriteLinesToFile File="%(CurrentOpenApiReference.OutputPath)"
+        Lines="$(_Lines)"
+        Overwrite="true" />
   </Target>
 
   <!-- NSwag's OpenApiReference support for TypeScript. -->
 
-  <Target Name="GenerateNSwagTypeScript">
-    <PropertyGroup>
-      <_Metadata>'%(FullPath)' Class: '%(Namespace).%(ClassName)'</_Metadata>
-      <_Metadata>$(_Metadata) FirstForGenerator: '%(FirstForGenerator)' Options: '%(Options)'</_Metadata>
-      <_Metadata>$(_Metadata) OutputPath: '%(OutputPath)'</_Metadata>
-    </PropertyGroup>
-
-    <Message Importance="high" Text="GenerateNSwagTypeScript $(_Metadata)" />
+  <Target Name="GenerateNSwagTypeScript" DependsOnTargets="_GetMetadataProperties">
+    <Message Importance="high" Text="$(_Message.Replace('__TargetName__', 'GenerateNSwagTypeScript'))" />
+    <WriteLinesToFile File="%(CurrentOpenApiReference.OutputPath)"
+        Lines="$(_Lines)"
+        Overwrite="true" />
   </Target>
 
   <!-- Custom OpenApiReference support for C#. -->
 
-  <Target Name="GenerateCustomCSharp">
-    <PropertyGroup>
-      <_Metadata>'%(FullPath)' Class: '%(Namespace).%(ClassName)'</_Metadata>
-      <_Metadata>$(_Metadata) FirstForGenerator: '%(FirstForGenerator)' Options: '%(Options)'</_Metadata>
-      <_Metadata>$(_Metadata) OutputPath: '%(OutputPath)'</_Metadata>
-    </PropertyGroup>
-
-    <Message Importance="high" Text="GenerateCustomCSharp $(_Metadata)" />
+  <Target Name="GenerateCustomCSharp" DependsOnTargets="_GetMetadataProperties">
+    <Message Importance="high" Text="$(_Message.Replace('__TargetName__', 'GenerateCustomCSharp'))" />
+    <WriteLinesToFile File="%(CurrentOpenApiReference.OutputPath)/Generated1.cs"
+        Lines="$(_Lines)"
+        Overwrite="true" />
+    <WriteLinesToFile File="%(CurrentOpenApiReference.OutputPath)/Generated2.cs"
+        Lines="$(_Lines)"
+        Overwrite="true" />
   </Target>
 </Project>
diff --git a/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs b/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs
index ad2ce63b1f2b760ff72cd72272129f516472607e..09039b9a9cc54fa817377f953d5567dec6619920 100644
--- a/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs
+++ b/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs
@@ -13,17 +13,6 @@ namespace Microsoft.Extensions.Tools.Internal;
 
 public class TemporaryCSharpProject
 {
-    private const string Template =
-@"<Project Sdk=""{2}"">
-  <PropertyGroup>
-    {0}
-    <OutputType>Exe</OutputType>
-  </PropertyGroup>
-  <ItemGroup>
-    {1}
-  </ItemGroup>
-</Project>";
-
     private readonly string _filename;
     private readonly TemporaryDirectory _directory;
     private readonly List<string> _items = new List<string>();
@@ -42,6 +31,17 @@ public class TemporaryCSharpProject
 
     public string Sdk { get; }
 
+    protected virtual string Template =>
+@"<Project Sdk=""{2}"">
+  <PropertyGroup>
+    {0}
+    <OutputType>Exe</OutputType>
+  </PropertyGroup>
+  <ItemGroup>
+    {1}
+  </ItemGroup>
+</Project>";
+
     public TemporaryCSharpProject WithTargetFrameworks(params string[] tfms)
     {
         Debug.Assert(tfms.Length > 0);
@@ -82,11 +82,16 @@ public class TemporaryCSharpProject
         if (item.Exclude != null) sb.Append(" Exclude=\"").Append(item.Exclude).Append('"');
         if (item.Condition != null) sb.Append(" Exclude=\"").Append(item.Condition).Append('"');
         if (!item.Watch) sb.Append(" Watch=\"false\" ");
+        AddAdditionalAttributes(sb, item);
         sb.Append(" />");
         _items.Add(sb.ToString());
         return this;
     }
 
+    protected virtual void AddAdditionalAttributes(StringBuilder sb, ItemSpec item)
+    {
+    }
+
     public TemporaryCSharpProject WithProjectReference(TemporaryCSharpProject reference, bool watch = true)
     {
         if (ReferenceEquals(this, reference))
diff --git a/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs b/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs
index d040908db8a6922230155ece0ddb174440c9e758..6869a362252ab55b798f524ada1deca33a46b29e 100644
--- a/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs
+++ b/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs
@@ -18,7 +18,7 @@ public class TemporaryDirectory : IDisposable
 
     public TemporaryDirectory()
     {
-        Root = Path.Combine(Path.GetTempPath(), "dotnet-tool-tests", Guid.NewGuid().ToString("N"));
+        Root = Path.Combine(ResolveLinks(Path.GetTempPath()), "dotnet-tool-tests", Guid.NewGuid().ToString("N"));
     }
 
     private TemporaryDirectory(string path, TemporaryDirectory parent)
@@ -39,13 +39,12 @@ public class TemporaryDirectory : IDisposable
     public TemporaryCSharpProject WithCSharpProject(string name, string sdk = "Microsoft.NET.Sdk")
     {
         var project = new TemporaryCSharpProject(name, this, sdk);
-        _projects.Add(project);
-        return project;
+        return WithCSharpProject(project);
     }
 
-    public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project, string sdk = "Microsoft.NET.Sdk")
+    public TemporaryCSharpProject WithCSharpProject(TemporaryCSharpProject project)
     {
-        project = WithCSharpProject(name, sdk);
+        _projects.Add(project);
         return project;
     }
 
@@ -115,4 +114,34 @@ public class TemporaryDirectory : IDisposable
             Console.Error.WriteLine($"Test cleanup failed to delete '{Root}'");
         }
     }
+
+    private static string ResolveLinks(string path)
+    {
+        if (!Directory.Exists(path))
+        {
+            return path;
+        }
+
+        var info = new DirectoryInfo(path);
+        var segments = new List<string>();
+        while (true)
+        {
+            if (info.LinkTarget is not null)
+            {
+                // Found a link, use it until we reach root. Portions of resolved path may also be links.
+                info = new DirectoryInfo(info.LinkTarget);
+            }
+
+            segments.Add(info.Name);
+            if (info.Parent is null)
+            {
+                break;
+            }
+
+            info = info.Parent;
+        }
+
+        segments.Reverse();
+        return Path.Combine(segments.ToArray());
+    }
 }