Skip to content

Commit 00af830

Browse files
MackinnonBuckSteveSandersonMS
authored andcommitted
Add project template build tests + CG reporting (#6355)
Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
1 parent b5a93cd commit 00af830

33 files changed

+1134
-50
lines changed

eng/build.proj

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Project Sdk="Microsoft.Build.Traversal">
22
<ItemGroup>
33
<_SnapshotsToExclude Include="$(MSBuildThisFileDirectory)..\test\**\Snapshots\**\*.*proj" />
4+
<_GeneratedContentToExclude Include="$(MSBuildThisFileDirectory)..\test\**\TemplateSandbox\**\*.*proj" />
45

56
<!-- We recursively add all of the projects inside the src directory, except for the exclusions above -->
67
<_ProjectsToBuild Include="$(MSBuildThisFileDirectory)..\src\**\*.csproj" />
@@ -11,6 +12,6 @@
1112
<_ProjectsToBuild Include="$(MSBuildThisFileDirectory)..\src\Packages\Microsoft.Internal.Extensions.DotNetApiDocs.Transport\Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj" />
1213

1314
<!-- Add all the projects we want to build as project references, so the traversal SDK can build them -->
14-
<ProjectReference Include="@(_ProjectsToBuild)" Exclude="@(_ProjectsToExclude);@(_SnapshotsToExclude)" />
15+
<ProjectReference Include="@(_ProjectsToBuild)" Exclude="@(_ProjectsToExclude);@(_SnapshotsToExclude);@(_GeneratedContentToExclude)" />
1516
</ItemGroup>
16-
</Project>
17+
</Project>

eng/pipelines/templates/BuildAndTest.yml

+21-4
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,25 @@ steps:
5656
--settings $(Build.SourcesDirectory)/eng/CodeCoverage.config
5757
--output ${{ parameters.repoTestResultsPath }}/$(Agent.JobName)_CodeCoverageResults/$(Agent.JobName)_cobertura.xml
5858
"${{ parameters.buildScript }} -test -configuration ${{ parameters.buildConfig }} /bl:${{ parameters.repoLogPath }}/tests.binlog $(_OfficialBuildIdArgs)"
59-
displayName: Run tests
59+
displayName: Run unit tests
60+
61+
- script: ${{ parameters.buildScript }}
62+
-pack
63+
-configuration ${{ parameters.buildConfig }}
64+
-warnAsError 1
65+
/bl:${{ parameters.repoLogPath }}/pack.binlog
66+
/p:Restore=false /p:Build=false
67+
$(_OfficialBuildIdArgs)
68+
displayName: Pack
69+
70+
- ${{ if ne(parameters.skipTests, 'true') }}:
71+
- script: ${{ parameters.buildScript }}
72+
-integrationTest
73+
-configuration ${{ parameters.buildConfig }}
74+
-warnAsError 1
75+
/bl:${{ parameters.repoLogPath }}/integration_tests.binlog
76+
$(_OfficialBuildIdArgs)
77+
displayName: Run integration tests
6078

6179
- pwsh: |
6280
$SourcesDirectory = '$(Build.SourcesDirectory)';
@@ -151,12 +169,11 @@ steps:
151169
displayName: Build Azure DevOps plugin
152170
153171
- script: ${{ parameters.buildScript }}
154-
-pack
155172
-sign $(_SignArgs)
156173
-publish $(_PublishArgs)
157174
-configuration ${{ parameters.buildConfig }}
158175
-warnAsError 1
159-
/bl:${{ parameters.repoLogPath }}/pack.binlog
176+
/bl:${{ parameters.repoLogPath }}/publish.binlog
160177
/p:Restore=false /p:Build=false
161178
$(_OfficialBuildIdArgs)
162-
displayName: Pack, sign, and publish
179+
displayName: Sign and publish
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace Microsoft.Extensions.AI.Templates.Tests;
11+
12+
/// <summary>
13+
/// Contains execution tests for the "AI Chat Web" template.
14+
/// </summary>
15+
/// <remarks>
16+
/// In addition to validating that the templates build and restore correctly,
17+
/// these tests are also responsible for template component governance reporting.
18+
/// This is because the generated output is left on disk after tests complete,
19+
/// most importantly the project.assets.json file that gets created during restore.
20+
/// Therefore, it's *critical* that these tests remain in a working state,
21+
/// as disabling them will also disable CG reporting.
22+
/// </remarks>
23+
public class AIChatWebExecutionTests : TemplateExecutionTestBase<AIChatWebExecutionTests>, ITemplateExecutionTestConfigurationProvider
24+
{
25+
public AIChatWebExecutionTests(TemplateExecutionTestFixture fixture, ITestOutputHelper outputHelper)
26+
: base(fixture, outputHelper)
27+
{
28+
}
29+
30+
public static TemplateExecutionTestConfiguration Configuration { get; } = new()
31+
{
32+
TemplatePackageName = "Microsoft.Extensions.AI.Templates",
33+
TestOutputFolderPrefix = "AIChatWeb"
34+
};
35+
36+
public static IEnumerable<object[]> GetBasicTemplateOptions()
37+
=> GetFilteredTemplateOptions("--aspire", "false");
38+
39+
public static IEnumerable<object[]> GetAspireTemplateOptions()
40+
=> GetFilteredTemplateOptions("--aspire", "true");
41+
42+
// Do not skip. See XML docs for this test class.
43+
[Theory]
44+
[MemberData(nameof(GetBasicTemplateOptions))]
45+
public async Task CreateRestoreAndBuild_BasicTemplate(params string[] args)
46+
{
47+
const string ProjectName = "BasicApp";
48+
var project = await Fixture.CreateProjectAsync(
49+
templateName: "aichatweb",
50+
projectName: ProjectName,
51+
args);
52+
53+
await Fixture.RestoreProjectAsync(project);
54+
await Fixture.BuildProjectAsync(project);
55+
}
56+
57+
// Do not skip. See XML docs for this test class.
58+
[Theory]
59+
[MemberData(nameof(GetAspireTemplateOptions))]
60+
public async Task CreateRestoreAndBuild_AspireTemplate(params string[] args)
61+
{
62+
const string ProjectName = "AspireApp";
63+
var project = await Fixture.CreateProjectAsync(
64+
templateName: "aichatweb",
65+
ProjectName,
66+
args);
67+
68+
project.StartupProjectRelativePath = $"{ProjectName}.AppHost";
69+
70+
await Fixture.RestoreProjectAsync(project);
71+
await Fixture.BuildProjectAsync(project);
72+
}
73+
74+
private static readonly (string name, string[] values)[] _templateOptions = [
75+
("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]),
76+
("--vector-store", ["azureaisearch", "local", "qdrant"]),
77+
("--managed-identity", ["true", "false"]),
78+
("--aspire", ["true", "false"]),
79+
];
80+
81+
private static IEnumerable<object[]> GetFilteredTemplateOptions(params string[] filter)
82+
{
83+
foreach (var options in GetAllPossibleOptions(_templateOptions))
84+
{
85+
if (!MatchesFilter())
86+
{
87+
continue;
88+
}
89+
90+
if (HasOption("--managed-identity", "true"))
91+
{
92+
if (HasOption("--aspire", "true"))
93+
{
94+
// The managed identity option is disabled for the Aspire template.
95+
continue;
96+
}
97+
98+
if (!HasOption("--vector-store", "azureaisearch") &&
99+
!HasOption("--aspire", "false"))
100+
{
101+
// Can only use managed identity when using Azure in the non-Aspire template.
102+
continue;
103+
}
104+
}
105+
106+
if (HasOption("--vector-store", "qdrant") &&
107+
HasOption("--aspire", "false"))
108+
{
109+
// Can't use Qdrant without Aspire.
110+
continue;
111+
}
112+
113+
yield return options;
114+
115+
bool MatchesFilter()
116+
{
117+
for (var i = 0; i < filter.Length; i += 2)
118+
{
119+
if (!HasOption(filter[i], filter[i + 1]))
120+
{
121+
return false;
122+
}
123+
}
124+
125+
return true;
126+
}
127+
128+
bool HasOption(string name, string value)
129+
{
130+
for (var i = 0; i < options.Length; i += 2)
131+
{
132+
if (string.Equals(name, options[i], StringComparison.Ordinal) &&
133+
string.Equals(value, options[i + 1], StringComparison.Ordinal))
134+
{
135+
return true;
136+
}
137+
}
138+
139+
return false;
140+
}
141+
}
142+
}
143+
144+
private static IEnumerable<string[]> GetAllPossibleOptions(ReadOnlyMemory<(string name, string[] values)> options)
145+
{
146+
if (options.Length == 0)
147+
{
148+
yield return [];
149+
yield break;
150+
}
151+
152+
var first = options.Span[0];
153+
foreach (var restSelection in GetAllPossibleOptions(options[1..]))
154+
{
155+
foreach (var value in first.values)
156+
{
157+
yield return [first.name, value, .. restSelection];
158+
}
159+
}
160+
}
161+
}
+4-5
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66
using System.IO;
77
using System.Linq;
88
using System.Threading.Tasks;
9-
using Microsoft.Extensions.AI.Templates.IntegrationTests;
109
using Microsoft.Extensions.AI.Templates.Tests;
1110
using Microsoft.Extensions.Logging;
1211
using Microsoft.TemplateEngine.Authoring.TemplateVerifier;
1312
using Microsoft.TemplateEngine.TestHelper;
1413
using Xunit;
1514
using Xunit.Abstractions;
1615

17-
namespace Microsoft.Extensions.AI.Templates.InegrationTests;
16+
namespace Microsoft.Extensions.AI.Templates.Tests;
1817

19-
public class AichatwebTemplatesTests : TestBase
18+
public class AIChatWebSnapshotTests
2019
{
2120
// Keep the exclude patterns below in sync with those in Microsoft.Extensions.AI.Templates.csproj.
2221
private static readonly string[] _verificationExcludePatterns = [
@@ -36,7 +35,7 @@ public class AichatwebTemplatesTests : TestBase
3635

3736
private readonly ILogger _log;
3837

39-
public AichatwebTemplatesTests(ITestOutputHelper log)
38+
public AIChatWebSnapshotTests(ITestOutputHelper log)
4039
{
4140
#pragma warning disable CA2000 // Dispose objects before losing scope
4241
_log = new XunitLoggerProvider(log).CreateLogger("TestRun");
@@ -67,7 +66,7 @@ private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable<string
6766
string templateShortName = "aichatweb";
6867

6968
// Get the template location
70-
string templateLocation = Path.Combine(TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "ChatWithCustomData");
69+
string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "ChatWithCustomData");
7170

7271
var verificationExcludePatterns = Path.DirectorySeparatorChar is '/'
7372
? _verificationExcludePatterns
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
6+
namespace Microsoft.Extensions.AI.Templates.Tests;
7+
8+
public class DotNetCommand : TestCommand
9+
{
10+
public DotNetCommand(params ReadOnlySpan<string> args)
11+
{
12+
FileName = WellKnownPaths.RepoDotNetExePath;
13+
14+
foreach (var arg in args)
15+
{
16+
Arguments.Add(arg);
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
using Xunit.Abstractions;
7+
8+
namespace Microsoft.Extensions.AI.Templates.Tests;
9+
10+
public sealed class DotNetNewCommand : DotNetCommand
11+
{
12+
private bool _customHiveSpecified;
13+
14+
public DotNetNewCommand(params ReadOnlySpan<string> args)
15+
: base(["new", .. args])
16+
{
17+
}
18+
19+
public DotNetNewCommand WithCustomHive(string path)
20+
{
21+
Arguments.Add("--debug:custom-hive");
22+
Arguments.Add(path);
23+
_customHiveSpecified = true;
24+
return this;
25+
}
26+
27+
public override Task<TestCommandResult> ExecuteAsync(ITestOutputHelper outputHelper)
28+
{
29+
if (!_customHiveSpecified)
30+
{
31+
// If this exception starts getting thrown in cases where a custom hive is
32+
// legitimately undesirable, we can add a new 'WithoutCustomHive()' method that
33+
// just sets '_customHiveSpecified' to 'true'.
34+
throw new InvalidOperationException($"A {nameof(DotNetNewCommand)} should specify a custom hive with '{nameof(WithCustomHive)}()'.");
35+
}
36+
37+
return base.ExecuteAsync(outputHelper);
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.Extensions.AI.Templates.Tests;
5+
6+
public interface ITemplateExecutionTestConfigurationProvider
7+
{
8+
static abstract TemplateExecutionTestConfiguration Configuration { get; }
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Xunit.Abstractions;
5+
using Xunit.Sdk;
6+
7+
namespace Microsoft.Extensions.AI.Templates.Tests;
8+
9+
public sealed class MessageSinkTestOutputHelper : ITestOutputHelper
10+
{
11+
private readonly IMessageSink _messageSink;
12+
13+
public MessageSinkTestOutputHelper(IMessageSink messageSink)
14+
{
15+
_messageSink = messageSink;
16+
}
17+
18+
public void WriteLine(string message)
19+
{
20+
_messageSink.OnMessage(new DiagnosticMessage(message));
21+
}
22+
23+
public void WriteLine(string format, params object[] args)
24+
{
25+
_messageSink.OnMessage(new DiagnosticMessage(format, args));
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace System.Diagnostics;
5+
6+
public static class ProcessExtensions
7+
{
8+
public static bool TryGetHasExited(this Process process)
9+
{
10+
try
11+
{
12+
return process.HasExited;
13+
}
14+
catch (InvalidOperationException ex) when (ex.Message.Contains("No process is associated with this object"))
15+
{
16+
return true;
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)