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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ It adds only the Revit execution model on top of TUnit; assertions, attributes,

## Non-negotiables

* One thread owns the Revit API. Every API call runs on the thread that initialized Revit; the executor marshals test bodies and hooks onto it and caps Revit tests to one at a time. Never touch a Revit type off that thread, and never start a second thread or `Task.Run` for Revit work.
* Inject and eject in matched pairs. The application connects once per test session and releases on the matching session-teardown hook.
* One thread owns the Revit API. Every API call runs on the thread that initialized Revit; the executor marshals test bodies and hooks onto it and declares a cap of one Revit test at a time. Never touch a Revit type off that thread, and never start a second thread or `Task.Run` for Revit work.
* Inject and eject in matched pairs, once per test host process. Revit activates once per process, and an IDE that keeps a test host alive starts a test session per run in that process. The first session that executes a test connects, and `RevitConnectionLifetime` releases when the test application finishes. A process that keeps Revit connected never terminates.
* Revit starts on the first test a session executes, never on discovery. Nothing that runs for a discovery request opens the connection. An IDE that lists the tests of an assembly does not start Revit.
* The package adds the Revit execution model only. It exposes the base classes, the executor, and the injection lifecycle; assertions, attributes, and discovery come from TUnit. Never reimplement what TUnit provides.
* Never break the public surface. Deprecate a renamed member with `[Obsolete]`, name the replacement, and keep the member functional.
* Mark a member the test platform invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`.
Expand All @@ -20,8 +21,9 @@ It adds only the Revit execution model on top of TUnit; assertions, attributes,

* A process-wide singleton starts one background STA thread and runs a WPF `Dispatcher` on it. The dispatcher pumps the Win32 messages COM marshaling needs and routes `await` continuations through `DispatcherSynchronizationContext`.
* `RevitThreadExecutor` is the public entry point. It queues the action onto the thread host and returns a task that completes once the body and its continuations finish. Unwrap the dispatcher operation (`operation.Task.Unwrap()`) to await the continuations.
* A `IParallelLimit` returning `1` holds the Revit thread exclusive; two tests cannot share it.
* `RevitApplicationTest` holds the static `Application`. `RevitApiTest` opens the connection before the session and closes it after, both on the Revit thread.
* `RevitThreadExecutor` declares an `IParallelLimit` of `1` through `ITestRegisteredEventReceiver`. TUnit applies it from the version that forwards the event to an installed executor. A TUnit without that forwarding schedules every Revit test at once, and the bodies interleave at each `await` on the shared thread. The limit belongs to TUnit; never reintroduce it here as an attribute on the base class.
* `RevitApplicationTest` holds the static `Application`. `RevitApiTest` opens the connection before the session, and `RevitConnectionLifetime` closes it after the test application, both on the Revit thread.
* `RevitConnectionLifetime` is an `ITestHostApplicationLifetime`. The package registers it through the `TestingPlatformBuilderHook` item of `build/Nice3point.TUnit.Revit.props`, packed into `build` and `buildTransitive`; a project that writes its own entry point calls `AddRevit` instead. The test project imports the same props, which a project reference does not deliver.

## Repository map

Expand Down
18 changes: 15 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# 2027.0.2

- One Revit connection now serves the whole test host process.
An IDE that keeps a test host alive starts a test session per run in that process, and every run after the first failed with `BeforeTestSession hook failed: Attempted to write protected memory.`
The new `RevitConnectionLifetime` releases the connection when the test application finishes.
The package registers it through its build props; a consuming project needs no change, and a project with its own entry point calls `AddRevit`.
- Removed the `RevitApiTest.RevitSessionCleanup` hook, which released the connection after every session.
- Revit tests still run without a parallel limit and interleave at every `await` on the shared thread.
The limit `RevitThreadExecutor` declares does not reach the scheduler: TUnit installs an executor without forwarding `ITestRegisteredEventReceiver` to it.
The fix belongs to TUnit and arrives with the version that carries it.
Until then, run the suite with `--maximum-parallel-tests 1`.

# 2027.0.1

- Updated TUnit to 1.44
Expand Down Expand Up @@ -30,7 +42,7 @@ TUnit initializes Revit with the `English - United States` language. To override

```csharp
using Nice3point.Revit.Injector.Attributes;

[assembly: RevitLanguage("ENU")]
```

Expand All @@ -56,7 +68,7 @@ TUnit initializes Revit from `C:\Program Files\Autodesk\Revit {version}` install

```csharp
using Nice3point.Revit.Injector.Attributes;

[assembly: RevitInstallationPath("D:\Autodesk\Revit Preview")]
```

Expand Down Expand Up @@ -96,4 +108,4 @@ Enable private Nuget source for testing

# 2026.0.0

Initial release. Enjoy!
Initial release. Enjoy!
29 changes: 20 additions & 9 deletions source/Nice3point.TUnit.Revit/Executors/RevitThreadExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ namespace Nice3point.TUnit.Revit.Executors;
/// </summary>
/// <remarks>
/// Revit requires every API call to occur on the same thread that initialised it.
/// All actions are queued to a process-wide STA thread driven by a WPF
/// <see cref="Dispatcher" />: it pumps Win32 messages for COM marshaling and routes
/// <c>await</c> continuations back to the same thread through
/// <see cref="DispatcherSynchronizationContext" />. Concurrent execution is capped
/// at one test at a time to keep exclusive access to the Revit thread.
/// All actions are queued to a process-wide STA thread driven by a WPF <see cref="Dispatcher" />:
/// it pumps Win32 messages for COM marshaling and routes <c>await</c> continuations back to the same thread through <see cref="DispatcherSynchronizationContext" />.
/// Concurrent execution is capped at one test at a time to keep exclusive access to the Revit thread.
/// </remarks>
public sealed class RevitThreadExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver
{
/// <summary>
/// Applies the Revit parallel limiter so registered tests never run concurrently.
/// </summary>
/// <param name="context">The registration context of the test the executor runs.</param>
/// <returns>A completed task.</returns>
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetParallelLimiter(RevitCountParallelLimit.Default);
Expand All @@ -33,11 +33,24 @@ protected override ValueTask ExecuteAsync(Func<ValueTask> action)
ArgumentNullException.ThrowIfNull(action);
return RevitDispatcherThread.Instance.InvokeAsync(action);
}

/// <summary>
/// Runs <paramref name="action"/> on the Revit thread outside of a test and a hook.
/// </summary>
/// <param name="action">The action to run on the Revit thread.</param>
/// <returns>A task that completes once the action and its continuations finish.</returns>
internal static ValueTask InvokeAsync(Func<ValueTask> action)
{
return RevitDispatcherThread.Instance.InvokeAsync(action);
}
}

/// <summary>
/// Restricts Revit API tests to a single concurrent execution.
/// </summary>
/// <remarks>
/// TUnit keys a limit by its type. Every Revit test of a run shares this one.
/// </remarks>
file sealed class RevitCountParallelLimit : IParallelLimit
{
/// <summary>
Expand All @@ -52,8 +65,7 @@ protected override ValueTask ExecuteAsync(Func<ValueTask> action)
}

/// <summary>
/// Hosts the process-wide STA thread used for every Revit API call and dispatches
/// asynchronous actions onto its WPF <see cref="Dispatcher" />.
/// Hosts the process-wide STA thread used for every Revit API call and dispatches asynchronous actions onto its WPF <see cref="Dispatcher" />.
/// </summary>
file sealed class RevitDispatcherThread
{
Expand Down Expand Up @@ -90,8 +102,7 @@ private RevitDispatcherThread()
public static RevitDispatcherThread Instance { get; } = new();

/// <summary>
/// Queues <paramref name="action" /> on the Revit thread and returns a task that
/// completes once the action and all of its <c>await</c> continuations finish.
/// Queues <paramref name="action" /> on the Revit thread and returns a task that completes once the action and all of its <c>await</c> continuations finish.
/// </summary>
public ValueTask InvokeAsync(Func<ValueTask> action)
{
Expand Down
4 changes: 4 additions & 0 deletions source/Nice3point.TUnit.Revit/Nice3point.TUnit.Revit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
<None Include="..\..\.nuget\PackageIcon.png" PackagePath="images\" Pack="true" Visible="false"/>
<None Include="..\..\LICENSE.md" PackagePath="" Pack="true" Visible="false"/>
<None Include="..\..\README.md" PackagePath="" Pack="true" Visible="false"/>

<!-- Build assets of the package. build reaches a direct reference, buildTransitive a transitive one. -->
<None Include="build\Nice3point.TUnit.Revit.props" PackagePath="build\" Pack="true"/>
<None Include="build\Nice3point.TUnit.Revit.props" PackagePath="buildTransitive\" Pack="true"/>
</ItemGroup>

<ItemGroup>
Expand Down
25 changes: 9 additions & 16 deletions source/Nice3point.TUnit.Revit/RevitApiTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,25 @@ namespace Nice3point.TUnit.Revit;

/// <summary>
/// Represents a test class for executing tests within the Revit environment.
/// This class provides dependency resolution, setup and cleanup methods for initializing and terminating
/// the connection to the Revit API before and after the test session.
/// This class provides dependency resolution and the setup that establishes the connection to the Revit API before the test session.
/// </summary>
/// <remarks>
/// <see cref="RevitConnectionLifetime"/> closes the connection when the test application finishes.
/// </remarks>
public abstract class RevitApiTest : RevitApplicationTest
{
/// <summary>
/// Sets up the Revit session by initializing the connection to the Revit API.
/// This method is executed before the test session begins, ensuring that the
/// necessary prerequisites for the tests interacting with the Revit environment are satisfied.
/// This method is executed before the test session begins, ensuring that the necessary prerequisites for the tests interacting with the Revit environment are satisfied.
/// </summary>
/// <remarks>
/// The first test a session executes triggers the hook.
/// Test discovery executes no test and does not start Revit.
/// </remarks>
[Before(TestSession)]
[HookExecutor<RevitThreadExecutor>]
public static void RevitSessionSetup()
{
InitializeRevitConnection();
}

/// <summary>
/// Cleans up the Revit session by terminating the connection to the Revit API.
/// This method is executed after the test session concludes, ensuring that
/// resources and connections related to the Revit environment are properly released.
/// </summary>
[After(TestSession)]
[HookExecutor<RevitThreadExecutor>]
public static void RevitSessionCleanup()
{
TerminateRevitConnection();
}
}
54 changes: 50 additions & 4 deletions source/Nice3point.TUnit.Revit/RevitApplicationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,76 @@ namespace Nice3point.TUnit.Revit;
/// Represents an abstract base class for tests that require interaction with the Revit application environment.
/// Provides methods to initialize and terminate the connection to the Revit application.
/// </summary>
/// <remarks>
/// One connection serves the whole test host process.
/// Revit activates once per process, and an IDE that keeps a test host alive starts a test session per run in that process.
/// </remarks>
public abstract class RevitApplicationTest
{
private static readonly Lock ConnectionLock = new();
private static Injector? _injector;

/// <summary>
/// Represents the database level Autodesk Revit Application, providing access to documents, options and other application wide data and settings.
/// </summary>
protected static Application Application { get; private set; } = null!;

/// <summary>
/// Gets a value indicating whether the process holds an open connection to the Revit application.
/// </summary>
internal static bool IsConnected
{
get
{
lock (ConnectionLock)
{
return _injector is not null;
}
}
}

/// <summary>
/// Initializes the connection to the Revit application.
/// </summary>
/// <exception cref="InvalidOperationException">The process terminated its connection earlier.</exception>
/// <remarks>
/// The first call of the process opens the connection, and every later call keeps the open one.
/// </remarks>
protected static void InitializeRevitConnection()
{
_injector = new Injector();
Application = _injector.InjectApplication();
lock (ConnectionLock)
{
if (_injector is not null)
{
return;
}

var injector = new Injector();
Application = injector.InjectApplication();
_injector = injector;
}
}

/// <summary>
/// Terminates the connection to the Revit application.
/// Frees associated resources and properly closes the interaction with the Revit environment.
/// </summary>
protected static void TerminateRevitConnection()
/// <remarks>
/// A call without an open connection has no effect.
/// The connection cannot be reopened in the same process.
/// </remarks>
protected internal static void TerminateRevitConnection()
{
_injector?.EjectApplication();
lock (ConnectionLock)
{
if (_injector is null)
{
return;
}

_injector.EjectApplication();
_injector = null;
Application = null!;
}
}
}
59 changes: 59 additions & 0 deletions source/Nice3point.TUnit.Revit/RevitConnectionLifetime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.ComponentModel;
using Microsoft.Testing.Platform.Extensions.TestHost;
using Nice3point.TUnit.Revit.Executors;

namespace Nice3point.TUnit.Revit;

/// <summary>
/// Releases the Revit connection when the test application finishes.
/// </summary>
/// <remarks>
/// The test platform runs this after the last test session of the process and before the runtime begins shutting down, in the console host of <c>dotnet run</c> and <c>dotnet test</c> as well as in the server host an IDE keeps alive between runs.
/// It is the last point at which the Revit thread still accepts work.
/// A process that keeps Revit connected never terminates.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class RevitConnectionLifetime : ITestHostApplicationLifetime
{
/// <inheritdoc />
public string Uid => nameof(RevitConnectionLifetime);

/// <inheritdoc />
public string Version => "1.0.0";

/// <inheritdoc />
public string DisplayName => "Revit connection lifetime";

/// <inheritdoc />
public string Description => "Releases the Revit connection on the Revit thread when the test application finishes.";

/// <inheritdoc />
public Task<bool> IsEnabledAsync()
{
return Task.FromResult(true);
}

/// <inheritdoc />
public Task BeforeRunAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}

/// <inheritdoc />
/// <remarks>
/// When the process holds no connection, as after a discovery request, this method returns without dispatching to the Revit thread.
/// </remarks>
public async Task AfterRunAsync(int exitCode, CancellationToken cancellationToken)
{
if (!RevitApplicationTest.IsConnected)
{
return;
}

await RevitThreadExecutor.InvokeAsync(static () =>
{
RevitApplicationTest.TerminateRevitConnection();
return default;
}).ConfigureAwait(false);
}
}
46 changes: 46 additions & 0 deletions source/Nice3point.TUnit.Revit/RevitRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.ComponentModel;
using Microsoft.Testing.Platform.Builder;

namespace Nice3point.TUnit.Revit;

/// <summary>
/// Provides extension methods for <see cref="ITestApplicationBuilder"/> to add the Revit execution model.
/// </summary>
public static class RevitRegistration
{
/// <param name="builder">The <see cref="ITestApplicationBuilder"/> to add the extensions to.</param>
extension(ITestApplicationBuilder builder)
{
/// <summary>
/// Adds the Revit connection lifetime to the specified <see cref="ITestApplicationBuilder"/>.
/// </summary>
/// <remarks>
/// The build props of the package add this extension to a test project.
/// A project that defines its own entry point calls this method from that entry point.
/// </remarks>
public void AddRevit()
{
builder.TestHost.AddTestHostApplicationLifetime(static _ => new RevitConnectionLifetime());
}
}
}

/// <summary>
/// Provides the hook Microsoft.Testing.Platform calls while it builds the test application.
/// </summary>
/// <remarks>
/// The <c>TestingPlatformBuilderHook</c> item of the package's build props names this type, and the build step of the test platform compiles a call to <see cref="AddExtensions"/> into the generated entry point.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class TestingPlatformBuilderHook
{
/// <summary>
/// Adds the Revit extensions to the specified <see cref="ITestApplicationBuilder"/>.
/// </summary>
/// <param name="builder">The <see cref="ITestApplicationBuilder"/> to add the extensions to.</param>
/// <param name="arguments">The command line arguments the test application was started with.</param>
public static void AddExtensions(ITestApplicationBuilder builder, string[] arguments)
{
builder.AddRevit();
}
}
11 changes: 11 additions & 0 deletions source/Nice3point.TUnit.Revit/build/Nice3point.TUnit.Revit.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>

<!-- An extension point of Microsoft.Testing.Platform. Registers the RevitConnectionLifetime. -->
<ItemGroup>
<TestingPlatformBuilderHook Include="Nice3point.TUnit.Revit">
<DisplayName>Nice3point.TUnit.Revit</DisplayName>
<TypeFullName>Nice3point.TUnit.Revit.TestingPlatformBuilderHook</TypeFullName>
</TestingPlatformBuilderHook>
</ItemGroup>

</Project>
Loading