Skip to content

Commit 96522f8

Browse files
authored
test: Isolate test repos
* Replace ReferenceRepository.ResetRepo with creation of a new instance * Add missing calls to Dispose * Add EpilogueAttribute in order to cleanup after ConfigureJoinableTaskFactory * Skip cleanup of test repos in CI environment * Reorder methods of GitModuleTestHelper Refs: gitextensions#12553, fixes gitextensions#12559
1 parent e054691 commit 96522f8

32 files changed

+441
-295
lines changed

tests/CommonTestUtils/CommonTestUtils.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3-
<PropertyGroup>
3+
<PropertyGroup>
44
<!-- TODO once all project migrated to SDK-style, remove this and move properties to Directory.Build.props -->
55
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
66

@@ -12,6 +12,10 @@
1212
<IsTestProject>false</IsTestProject>
1313
</PropertyGroup>
1414

15+
<PropertyGroup Condition="'$(ContinuousIntegrationBuild)' == 'true'">
16+
<DefineConstants>$(DefineConstants);CI_BUILD</DefineConstants>
17+
</PropertyGroup>
18+
1519
<ItemGroup>
1620
<PackageReference Include="LibGit2Sharp" />
1721
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" />

tests/CommonTestUtils/Epilogue.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.ComponentModel;
2+
3+
namespace CommonTestUtils;
4+
5+
/// <summary>
6+
/// This class supports TestCleanUpAttribute. See TestCleanUpAttribute.cs
7+
/// for technical details.
8+
/// </summary>
9+
public static class Epilogue
10+
{
11+
private static readonly Lock _sync = new();
12+
private static readonly OrderedDictionary<int, Action> _afterSuiteActions = [];
13+
private static readonly List<Action> _afterTestActions = [];
14+
15+
/// <summary>
16+
/// Registers an action to be executed after the completion of
17+
/// the test suite. Wait actions are executed synchronously in
18+
/// the sequence indicated by <paramref name="order"/>. Values
19+
/// of <paramref name="order"/> must be unique.The wait action
20+
/// registration is permanent. The <see cref="ExecuteAfterTestActions"/> method is
21+
/// called by <see cref="EpilogueAttribute"/> in order to execute
22+
/// the current plan of action.
23+
/// </summary>
24+
/// <param name="order">The position in sequence to run this action.</param>
25+
/// <param name="action">The action to be called.</param>
26+
public static void RegisterAfterSuiteAction(int order, Action action)
27+
{
28+
lock (_sync)
29+
{
30+
_afterSuiteActions.Add(order, action);
31+
}
32+
}
33+
34+
/// <summary>
35+
/// Registers an action to be performed after the completion of the
36+
/// current test, including the completion of any
37+
/// <see cref="NUnit.Framework.ITestAction.AfterTest(NUnit.Framework.Interfaces.ITest)"/>
38+
/// callbacks. The <see cref="ExecuteAfterSuiteActions"/> method is
39+
/// called by <see cref="EpilogueAttribute"/> in order to execute
40+
/// the current plan of action. Clean-up actions are called only once
41+
/// and need to be registered each time they are needed.
42+
/// </summary>
43+
/// <param name="action">The clean-up action to run after the current test.</param>
44+
public static void RegisterAfterTestAction(Action action)
45+
{
46+
lock (_sync)
47+
{
48+
_afterTestActions.Add(action);
49+
}
50+
}
51+
52+
/// <summary>
53+
/// This method supports the <see cref="Epilogue"/> infrastructure.
54+
/// It is called by <see cref="EpilogueAttribute"/>, which is an
55+
/// implementation of NUnit <see cref="NUnit.Framework.ITestAction"/>,
56+
/// after the end of the current test.
57+
/// </summary>
58+
[EditorBrowsable(EditorBrowsableState.Never)]
59+
public static void ExecuteAfterTestActions()
60+
{
61+
lock (_sync)
62+
{
63+
_afterTestActions.ForEach(action => action());
64+
_afterTestActions.Clear();
65+
}
66+
}
67+
68+
/// <summary>
69+
/// This method supports the <see cref="Epilogue"/> infrastructure.
70+
/// It is called by <see cref="EpilogueAttribute"/>, which is an
71+
/// implementation of NUnit <see cref="NUnit.Framework.ITestAction"/>,
72+
/// after the end of the test suite.
73+
/// </summary>
74+
[EditorBrowsable(EditorBrowsableState.Never)]
75+
public static void ExecuteAfterSuiteActions()
76+
{
77+
lock (_sync)
78+
{
79+
_afterSuiteActions.Values.ForEach(action => action());
80+
}
81+
}
82+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using NUnit.Framework;
2+
using NUnit.Framework.Interfaces;
3+
4+
namespace CommonTestUtils;
5+
6+
// Declare [assembly: TestCleanUp] in any test library that uses
7+
// GitModuleTestHelper or ReferenceRepository (which uses
8+
// GitModuleTestHelper internally). Otherwise, the temporary
9+
// repositories created in the temp folder for use in tests
10+
// won't get cleaned up.
11+
//
12+
// What this class does:
13+
//
14+
// Some tests create a physical Git repository on-disk as part of the
15+
// environment in which to perform the test. This needs to be cleaned
16+
// up. But, the test run's duration is measurably increased by doing
17+
// this clean-up synchronously. So, instead, the clean-up is run in a
18+
// background task. But, when the [ConfigureJoinableTaskFactory]
19+
// attribute is used, there is an AfterTest action that runs _after_
20+
// the test's TearDown is complete. The purpose of that AfterTest
21+
// action is to block until all tasks started within the test have
22+
// completed. If the TearDown kicks off clean-up of the repository,
23+
// it could delete the files out from under code that is still running.
24+
// This won't cause the test to fail, because it's a background task
25+
// fired up in the ambient ThreadHelper.JoinableTaskContext, but it
26+
// can cause unhandled exceptions to occur, resulting in a messagebox
27+
// appearing in the middle of the test run. And, this exception blocks
28+
// the worker task, and the AfterTest hook supplied by the
29+
// [ConfigureJoinableTaskFactory] joins on the worker task, so the test
30+
// run is blocked from continuing until the exception dialog is dealt
31+
// with.
32+
//
33+
// To resolve this issue, clean-up actions are deferred until after
34+
// the AfterTest hook from [ConfigureJoinableTestFactory] has finished
35+
// joining the threads. This is done by declaring this attribute before
36+
// the [ConfigureJoinableTestFactory]; this causes NUnit to process
37+
// this ITestAction for BeforeTest first and AfterTest last.
38+
//
39+
// ThreadCleanUp.BeforeTest
40+
// ConfigureJoinableTestFactory.BeforeTest
41+
// Test.SetUp
42+
// Test
43+
// Test.TearDown
44+
// ConfigureJoinableTestFactory.AfterTest -> wait for detached tasks
45+
// ThreadCleanUp.AfterTest -> kick off clean-up tasks
46+
//
47+
// The clean-up tasks are thus kept from interfering with detached
48+
// operations started during the test.
49+
//
50+
// It is possible at the end of the test suite execution for a clean-
51+
// up action to still be in progress when the test process is ready
52+
// to exit. There is a theoretical possibility that a clean-up action
53+
// might fail to run or be interrupted. To avoid this possibility,
54+
// TestCleanUpAttribute also registers with NUnit for the Suite
55+
// target. This allows it to receive BeforeTest and AfterTest
56+
// notifications for the overall test suite, and the AfterTest
57+
// callback can be used to wait until all ongoing clean-up operations
58+
// are complete.
59+
//
60+
// ThreadCleanUp.BeforeTest (suite)
61+
// ThreadCleanUp.BeforeTest
62+
// ConfigureJoinableTestFactory.BeforeTest
63+
// Test.SetUp
64+
// Test
65+
// Test.TearDown
66+
// ConfigureJoinableTestFactory.AfterTest -> wait for detached tasks
67+
// ThreadCleanUp.AfterTest -> kick off clean-up tasks
68+
// ...
69+
// ThreadCleanUp.BeforeTest
70+
// ConfigureJoinableTestFactory.BeforeTest
71+
// Test.SetUp
72+
// Test
73+
// Test.TearDown
74+
// ConfigureJoinableTestFactory.AfterTest -> wait for detached tasks
75+
// ThreadCleanUp.AfterTest -> kick off clean-up tasks
76+
// ThreadCleanUp.AfterTest (suite) -> wait for clean-up tasks before exit
77+
[AttributeUsage(AttributeTargets.Assembly)]
78+
public class EpilogueAttribute : Attribute, ITestAction
79+
{
80+
public ActionTargets Targets => ActionTargets.Suite | ActionTargets.Test;
81+
82+
public void BeforeTest(ITest test)
83+
{
84+
}
85+
86+
public void AfterTest(ITest test)
87+
{
88+
if (test.IsSuite)
89+
{
90+
Epilogue.ExecuteAfterSuiteActions();
91+
}
92+
else
93+
{
94+
Epilogue.ExecuteAfterTestActions();
95+
}
96+
}
97+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using GitUI;
2+
using Microsoft.VisualStudio.Threading;
3+
4+
namespace CommonTestUtils;
5+
6+
public partial class GitModuleTestHelper : IDisposable
7+
{
8+
#if CI_BUILD
9+
static GitModuleTestHelper()
10+
{
11+
Console.WriteLine("GitModuleTestHelper: Disabling explicit clean-up for continuous integration test environment");
12+
}
13+
14+
private void FileAndForgetCleanUp()
15+
{
16+
}
17+
18+
public static void WaitForCleanUpCompletion()
19+
{
20+
}
21+
#else
22+
private static readonly TaskManager _cleanUpOperations = new(new JoinableTaskContext());
23+
24+
static GitModuleTestHelper()
25+
{
26+
Console.WriteLine("GitModuleTestHelper: Will perform clean-up in background tasks");
27+
28+
Epilogue.RegisterAfterSuiteAction(order: 2, WaitForCleanUpCompletion);
29+
}
30+
31+
private static void CleanUp(string path)
32+
{
33+
try
34+
{
35+
// Directory.Delete seems to intermittently fail, so delete the files first before deleting folders
36+
foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
37+
{
38+
if (File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint))
39+
{
40+
continue;
41+
}
42+
43+
File.SetAttributes(file, FileAttributes.Normal);
44+
File.Delete(file);
45+
}
46+
47+
// Delete tends to fail on the first try, so give it a few tries as a best effort.
48+
// By this point, all files have been deleted anyway, so this is mainly about removing
49+
// empty directories.
50+
for (int tries = 0; tries < 10; ++tries)
51+
{
52+
try
53+
{
54+
Directory.Delete(path, true);
55+
break;
56+
}
57+
catch
58+
{
59+
}
60+
}
61+
}
62+
catch
63+
{
64+
// do nothing
65+
}
66+
}
67+
68+
private void FileAndForgetCleanUp()
69+
{
70+
Epilogue.RegisterAfterTestAction(() => { _cleanUpOperations.FileAndForget(() => CleanUp(TemporaryPath)); });
71+
}
72+
73+
public static void WaitForCleanUpCompletion()
74+
{
75+
_cleanUpOperations.JoinPendingOperations();
76+
}
77+
#endif
78+
}

0 commit comments

Comments
 (0)