Skip to content

Enable running example tests massively in parallel via dotnet test or UI #1337

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: draft-v8
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/tools-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ jobs:
- name: Run all tests
run: |
cd tools
dotnet test
dotnet test --filter Name!~ExampleTests
2 changes: 2 additions & 0 deletions tools/ExampleExtractor/ExampleMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,6 @@ public class ExampleMetadata

[JsonIgnore]
public string Source => $"{MarkdownFile}:{StartLine}-{EndLine}";

public override string ToString() => Name;
}
6 changes: 5 additions & 1 deletion tools/ExampleTester/ExampleTester.csproj
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
Comment on lines +15 to +16
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could not figure out a way to get xUnit to parallelize between individual cases within a theory. Also, the Visual Studio Test Explorer UI for an xUnit theory does not show theory cases as individual nested tests, so they can't be individually run or added to playlists. NUnit shines in this area.

</ItemGroup>

Expand Down
35 changes: 35 additions & 0 deletions tools/ExampleTester/ExampleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using NUnit.Framework;
using Utilities;

[assembly: Parallelizable(ParallelScope.Children)]

namespace ExampleTester;

public static class ExampleTests
{
private static TesterConfiguration TesterConfiguration { get; } = new(Path.Join(FindSlnDirectory(), "tmp"));

public static IEnumerable<object[]> LoadExamples() =>
from example in GeneratedExample.LoadAllExamples(TesterConfiguration.ExtractedOutputDirectory)
select new object[] { example };

[TestCaseSource(nameof(LoadExamples))]
public static async Task ExamplePasses(GeneratedExample example)
{
var logger = new StatusCheckLogger(TestContext.Out, "..", "Example tester");

if (!await example.Test(TesterConfiguration, logger))
Assert.Fail("There were one or more failures. See the logged output for details.");
}

private static string FindSlnDirectory()
{
for (string? current = AppDomain.CurrentDomain.BaseDirectory; current != null; current = Path.GetDirectoryName(current))
{
if (Directory.EnumerateFiles(current, "*.sln").Any())
return current;
}

throw new InvalidOperationException($"Can't find a directory containing a .sln file in {AppDomain.CurrentDomain.BaseDirectory} or any parent directories.");
}
}
70 changes: 40 additions & 30 deletions tools/ExampleTester/GeneratedExample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

namespace ExampleTester;

internal class GeneratedExample
public class GeneratedExample
{
private static readonly object ConsoleAccessLock = new();

private readonly string directory;
internal ExampleMetadata Metadata { get; }

Expand All @@ -20,6 +22,8 @@ private GeneratedExample(string directory)
Metadata = JsonConvert.DeserializeObject<ExampleMetadata>(metadataJson) ?? throw new ArgumentException($"Invalid (null) metadata in {directory}");
}

public override string? ToString() => Metadata.ToString();

internal static List<GeneratedExample> LoadAllExamples(string parentDirectory) =>
Directory.GetDirectories(parentDirectory).Select(Load).ToList();

Expand Down Expand Up @@ -127,46 +131,52 @@ bool ValidateOutput()
? new object[] { Metadata.ExecutionArgs ?? new string[0] }
: new object[0];

var oldOut = Console.Out;
List<string> actualLines;
Exception? actualException = null;
try
lock (ConsoleAccessLock)
{
var builder = new StringBuilder();
Console.SetOut(new StringWriter(builder));
var oldOut = Console.Out;
try
{
var result = method.Invoke(null, arguments);
// For async Main methods, the compilation's entry point is still the Main
// method, so we explicitly wait for the returned task just like the synthesized
// entry point would.
if (result is Task task)
var builder = new StringBuilder();
Console.SetOut(new StringWriter(builder));
try
{
var result = method.Invoke(null, arguments);
// For async Main methods, the compilation's entry point is still the Main
// method, so we explicitly wait for the returned task just like the synthesized
// entry point would.
if (result is Task task)
{
task.GetAwaiter().GetResult();
}

// For some reason, we don't *actually* get the result of all finalizers
// without this. We shouldn't need it (as relevant examples already have it) but
// code that works outside the test harness doesn't work inside it.
GC.Collect();
GC.WaitForPendingFinalizers();
}
catch (TargetInvocationException outer)
{
task.GetAwaiter().GetResult();
actualException = outer.InnerException ?? throw new InvalidOperationException("TargetInvocationException had no nested exception");
}
// For some reason, we don't *actually* get the result of all finalizers
// without this. We shouldn't need it (as relevant examples already have it) but
// code that works outside the test harness doesn't work inside it.
GC.Collect();
GC.WaitForPendingFinalizers();

// Skip blank lines, to avoid unnecessary trailing empties.
// Also trim the end of each actual line, to avoid trailing spaces being necessary in the metadata
// or listed console output.
actualLines = builder.ToString()
.Replace("\r\n", "\n")
.Split('\n')
.Select(line => line.TrimEnd())
.Where(line => line != "").ToList();
}
catch (TargetInvocationException outer)
finally
{
actualException = outer.InnerException ?? throw new InvalidOperationException("TargetInvocationException had no nested exception");
Console.SetOut(oldOut);
}
// Skip blank lines, to avoid unnecessary trailing empties.
// Also trim the end of each actual line, to avoid trailing spaces being necessary in the metadata
// or listed console output.
actualLines = builder.ToString()
.Replace("\r\n", "\n")
.Split('\n')
.Select(line => line.TrimEnd())
.Where(line => line != "").ToList();
}
finally
{
Console.SetOut(oldOut);
}

var expectedLines = Metadata.ExpectedOutput ?? new List<string>();
return ValidateException(actualException, Metadata.ExpectedException) &&
(Metadata.IgnoreOutput || ValidateExpectedAgainstActual("output", expectedLines, actualLines));
Expand Down
2 changes: 1 addition & 1 deletion tools/ExampleTester/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
using System.CommandLine;
using Utilities;

var logger = new StatusCheckLogger("..", "Example tester");
var logger = new StatusCheckLogger(Console.Out, "..", "Example tester");
var headSha = Environment.GetEnvironmentVariable("HEAD_SHA");
var token = Environment.GetEnvironmentVariable("GH_TOKEN");

Expand Down
9 changes: 3 additions & 6 deletions tools/ExampleTester/TesterConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@ namespace ExampleTester;

public record TesterConfiguration(
string ExtractedOutputDirectory,
bool Quiet,
string? SourceFile,
string? ExampleName)
{

}
bool Quiet = false,
string? SourceFile = null,
string? ExampleName = null);

public class TesterConfigurationBinder : BinderBase<TesterConfiguration>
{
Expand Down
2 changes: 1 addition & 1 deletion tools/MarkdownConverter/Spec/Reporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public Reporter() : this(null, null) { }
public Reporter(Reporter? parent, string? filename)
{
// This is needed so that all Reporters share the same GitHub logger.
this.githubLogger = parent?.githubLogger ?? new StatusCheckLogger("..", "Markdown to Word Converter");
this.githubLogger = parent?.githubLogger ?? new StatusCheckLogger(Console.Out, "..", "Markdown to Word Converter");
this.parent = parent;
Location = new SourceLocation(filename, null, null, null);
}
Expand Down
2 changes: 1 addition & 1 deletion tools/StandardAnchorTags/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class Program
/// <returns>0 on success, non-zero on failure</returns>
static async Task<int> Main(string owner, string repo, bool dryrun =false)
{
var logger = new StatusCheckLogger("..", "TOC and Anchor updater");
var logger = new StatusCheckLogger(Console.Out, "..", "TOC and Anchor updater");
var headSha = Environment.GetEnvironmentVariable("HEAD_SHA");
var token = Environment.GetEnvironmentVariable("GH_TOKEN");
using FileStream openStream = File.OpenRead(FilesPath);
Expand Down
10 changes: 5 additions & 5 deletions tools/Utilities/StatusCheckLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ public record StatusCheckMessage(string file, int StartLine, int EndLine, string
/// </remarks>
/// <param name="pathToRoot">The path to the root of the repository</param>
/// <param name="toolName">The name of the tool that is running the check</param>
public class StatusCheckLogger(string pathToRoot, string toolName)
public class StatusCheckLogger(TextWriter writer, string pathToRoot, string toolName)
{
private List<NewCheckRunAnnotation> annotations = [];
public bool Success { get; private set; } = true;

// Utility method to format the path to unix style, from the root of the repository.
private string FormatPath(string path) => Path.GetRelativePath(pathToRoot, path).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

private void WriteMessageToConsole(string prefix, StatusCheckMessage d) => Console.WriteLine($"{prefix}{toolName}-{d.Id}::file={FormatPath(d.file)},line={d.StartLine}::{d.Message}");
private void WriteMessageToConsole(string prefix, StatusCheckMessage d) => writer.WriteLine($"{prefix}{toolName}-{d.Id}::file={FormatPath(d.file)},line={d.StartLine}::{d.Message}");

/// <summary>
/// Log a notice from the status check to the console only
Expand Down Expand Up @@ -178,9 +178,9 @@ public async Task BuildCheckRunResult(string token, string owner, string repo, s
// Once running on a branch on the dotnet org, this should work correctly.
catch (ForbiddenException e)
{
Console.WriteLine("===== WARNING: Could not create a check run.=====");
Console.WriteLine("Exception details:");
Console.WriteLine(e);
writer.WriteLine("===== WARNING: Could not create a check run.=====");
writer.WriteLine("Exception details:");
writer.WriteLine(e);
}
}
}
Loading