// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Maui.Annotations;
using Aspire.Hosting.Maui.Lifecycle;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Maui.Tests;
/// <summary>
/// Tests for the MAUI build queue that serializes builds per-project.
/// Uses a <see cref="TestableBuildQueueSubscriber"/> that overrides
/// <see cref="MauiBuildQueueEventSubscriber.RunBuildAsync"/> with a
/// controllable <see cref="TaskCompletionSource"/> per resource.
/// </summary>
public class MauiBuildQueueTests
{
[Fact]
public void BuildQueueAnnotation_SemaphoreInitializedToOne()
{
var annotation = new MauiBuildQueueAnnotation();
Assert.Equal(1, annotation.BuildSemaphore.CurrentCount);
}
[Fact]
public void BuildQueueAnnotation_AddedByAddMauiProject()
{
var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj");
parent.Annotations.Add(new MauiBuildQueueAnnotation());
Assert.True(parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out _));
}
[Fact]
public void BuildQueueAnnotation_CancelResource_DoesNotSurfaceCallbackExceptions()
{
var annotation = new MauiBuildQueueAnnotation();
var cts = new CancellationTokenSource();
using var _ = cts.Token.Register(() => throw new InvalidOperationException("callback failure"));
annotation.ResourceCancellations["android"] = cts;
Assert.True(annotation.CancelResource("android"));
}
[Fact]
public async Task SingleResource_AcquiresSemaphore()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// Start the event but don't complete the build yet — semaphore should be held.
var eventTask = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount);
env.Subscriber.CompleteBuild(env.Android);
await eventTask.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task SingleResource_ReleasesSemaphoreAfterBuild()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
env.Subscriber.CompleteBuildImmediately(env.Android);
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None);
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
}
[Fact]
public async Task SecondResource_BlocksUntilBuildCompletes()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
var queued = WaitForStateAsync(env, env.MacCatalyst, "Queued");
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
CancellationToken.None));
await queued;
Assert.False(task2.IsCompleted, "Second resource should be blocked by the queue.");
// Complete first build — second should start.
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
// Complete second build.
env.Subscriber.CompleteBuild(env.MacCatalyst);
await task2.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task SecondResource_ShowsQueuedState()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
var queuedSeen = new TaskCompletionSource<bool>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = Task.Run(async () =>
{
await foreach (var evt in env.NotificationService.WatchAsync(cts.Token))
{
if (evt.Resource.Name == env.MacCatalyst.Name && evt.Snapshot.State?.Text == "Queued")
{
queuedSeen.TrySetResult(true);
return;
}
}
}, cts.Token);
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
CancellationToken.None));
var result = await queuedSeen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(result);
// Clean up: complete both builds and await their tasks.
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
env.Subscriber.CompleteBuild(env.MacCatalyst);
await task2.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task SingleResource_ShowsBuildingState()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var buildingSeen = new TaskCompletionSource<bool>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = Task.Run(async () =>
{
await foreach (var evt in env.NotificationService.WatchAsync(cts.Token))
{
if (evt.Resource.Name == env.Android.Name && evt.Snapshot.State?.Text == "Building")
{
buildingSeen.TrySetResult(true);
return;
}
}
}, cts.Token);
var eventTask = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
var result = await buildingSeen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(result);
env.Subscriber.CompleteBuild(env.Android);
await eventTask.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ResourcesFromDifferentProjects_RunConcurrently()
{
await using var env = await BuildQueueTestEnvironment.CreateWithTwoProjectsAsync();
// Start both events WITHOUT completing builds — both should enter Building concurrently.
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android2!, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
await env.Subscriber.WaitForBuildStartedAsync(env.Android2!);
// Both should be building simultaneously — neither should have completed,
// proving they are NOT serialized across different projects.
Assert.False(task1.IsCompleted, "Task1 should still be building.");
Assert.False(task2.IsCompleted, "Task2 should still be building.");
// Complete both builds.
env.Subscriber.CompleteBuild(env.Android);
env.Subscriber.CompleteBuild(env.Android2!);
await Task.WhenAll(task1, task2).WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task FailedBuild_ReleasesQueueAndThrows()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
env.Subscriber.FailBuild(env.Android, "Compilation error");
await Assert.ThrowsAsync<InvalidOperationException>(
() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
// Semaphore should be released even after failure.
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
}
[Fact]
public async Task CancelledQueuedResource_DoesNotDeadlock()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
using var cts = new CancellationTokenSource();
var queued = WaitForStateAsync(env, env.MacCatalyst, "Queued");
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
cts.Token));
await queued;
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => task2.WaitAsync(TimeSpan.FromSeconds(5)));
// Complete first build — semaphore should still work for a third resource.
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
env.Subscriber.CompleteBuildImmediately(env.IOSSimulator);
var task3 = env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.IOSSimulator, env.Services),
CancellationToken.None);
await task3.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ThreeResources_ExecuteInSequence()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var completionOrder = new List<string>();
var task1 = Task.Run(async () =>
{
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None);
lock (completionOrder) { completionOrder.Add("android"); }
});
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
var macCatalystQueued = WaitForStateAsync(env, env.MacCatalyst, "Queued");
var iosSimulatorQueued = WaitForStateAsync(env, env.IOSSimulator, "Queued");
var task2 = Task.Run(async () =>
{
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
CancellationToken.None);
lock (completionOrder) { completionOrder.Add("maccatalyst"); }
});
var task3 = Task.Run(async () =>
{
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.IOSSimulator, env.Services),
CancellationToken.None);
lock (completionOrder) { completionOrder.Add("ios"); }
});
await Task.WhenAll(macCatalystQueued, iosSimulatorQueued);
Assert.Empty(completionOrder);
Assert.False(task2.IsCompleted);
Assert.False(task3.IsCompleted);
// Complete Android — one of the queued resources will acquire the semaphore next.
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(completionOrder);
Assert.Equal("android", completionOrder[0]);
// After Android's event handler completes, the semaphore is released.
// One of the two waiting tasks acquires it next — order is non-deterministic
// since SemaphoreSlim doesn't guarantee FIFO. Wait for either to start.
var macTask = env.Subscriber.WaitForBuildStartedAsync(env.MacCatalyst, TimeSpan.FromSeconds(30));
var iosTask = env.Subscriber.WaitForBuildStartedAsync(env.IOSSimulator, TimeSpan.FromSeconds(30));
var secondStarted = await Task.WhenAny(macTask, iosTask);
await secondStarted; // propagate exceptions
// Complete whichever started, then wait for the other.
if (secondStarted == macTask)
{
env.Subscriber.CompleteBuild(env.MacCatalyst);
await task2.WaitAsync(TimeSpan.FromSeconds(5));
await iosTask;
env.Subscriber.CompleteBuild(env.IOSSimulator);
await task3.WaitAsync(TimeSpan.FromSeconds(5));
}
else
{
env.Subscriber.CompleteBuild(env.IOSSimulator);
await task3.WaitAsync(TimeSpan.FromSeconds(5));
await macTask;
env.Subscriber.CompleteBuild(env.MacCatalyst);
await task2.WaitAsync(TimeSpan.FromSeconds(5));
}
// All three completed in sequence (android first, then the other two in either order).
Assert.Equal(3, completionOrder.Count);
Assert.Equal("android", completionOrder[0]);
Assert.Contains("maccatalyst", completionOrder);
Assert.Contains("ios", completionOrder);
}
[Fact]
public async Task NonMauiResource_IsNotAffected()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
// Parent MauiProjectResource is NOT IMauiPlatformResource — should pass through.
var parentTask = env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Parent, env.Services),
CancellationToken.None);
await parentTask.WaitAsync(TimeSpan.FromSeconds(2));
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ResourceRestart_CanBuildSameResourceTwice()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// First build
env.Subscriber.CompleteBuildImmediately(env.Android);
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None);
// Semaphore released after first build
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
// Second build of same resource (restart scenario)
env.Subscriber.CompleteBuildImmediately(env.Android);
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None);
Assert.Equal(1, annotation.BuildSemaphore.CurrentCount);
}
[Fact]
public async Task MissingBuildQueueAnnotation_SkipsQueue()
{
// Create a parent without MauiBuildQueueAnnotation
var appBuilder = DistributedApplication.CreateBuilder();
var parent = new MauiProjectResource("mauiapp-no-annotation", "/fake/path.csproj");
appBuilder.CreateResourceBuilder(parent);
var android = new MauiAndroidEmulatorResource("android-no-annotation", parent);
appBuilder.AddResource(android);
var app = appBuilder.Build();
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
var loggerService = app.Services.GetRequiredService<ResourceLoggerService>();
var eventing = app.Services.GetRequiredService<IDistributedApplicationEventing>();
var execContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
var subscriber = new TestableBuildQueueSubscriber(
notificationService,
loggerService);
await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None);
// Should return immediately — no annotation means no queue
await eventing.PublishAsync(
new BeforeResourceStartedEvent(android, app.Services),
CancellationToken.None);
await app.DisposeAsync();
}
[Fact]
public async Task MissingBuildInfoAnnotation_ThrowsAndReleasesSemaphore()
{
// Use the real subscriber (not testable) to exercise the RunBuildAsync path
// where MauiBuildInfoAnnotation is absent.
var appBuilder = DistributedApplication.CreateBuilder();
var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj");
parent.Annotations.Add(new MauiBuildQueueAnnotation());
appBuilder.CreateResourceBuilder(parent);
var android = new MauiAndroidEmulatorResource("android", parent);
appBuilder.AddResource(android);
var app = appBuilder.Build();
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
var loggerService = app.Services.GetRequiredService<ResourceLoggerService>();
var eventing = app.Services.GetRequiredService<IDistributedApplicationEventing>();
var execContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
// Use real subscriber — android has no MauiBuildInfoAnnotation.
// Override ReleaseSemaphoreAfterLaunchAsync to release immediately since
// there is no DCP launch phase in the test environment.
var subscriber = new RealBuildQueueSubscriberWithImmediateRelease(
notificationService,
loggerService);
await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None);
// Should throw InvalidOperationException — missing annotation means semaphore
// would be held indefinitely without this guard.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await eventing.PublishAsync(
new BeforeResourceStartedEvent(android, app.Services),
CancellationToken.None));
Assert.Contains("MauiBuildInfoAnnotation", ex.Message);
// Semaphore should still be released via the finally block
Assert.True(parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
await app.DisposeAsync();
}
[Fact]
public async Task UnexpectedException_ReleasesSemaphore()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
env.Subscriber.FailBuildWith(env.Android, new ArgumentException("Unexpected error"));
await Assert.ThrowsAsync<ArgumentException>(
() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
// Semaphore should be released even for unexpected exception types.
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
}
[Fact]
public void BuildInfoAnnotation_StoresAllProperties()
{
var annotation = new MauiBuildInfoAnnotation(
"/path/to/project.csproj",
"/path/to",
"net10.0-android",
"Release",
["-p:RuntimeIdentifier=ios-arm64"]);
Assert.Equal("/path/to/project.csproj", annotation.ProjectPath);
Assert.Equal("/path/to", annotation.WorkingDirectory);
Assert.Equal("net10.0-android", annotation.TargetFramework);
Assert.Equal("Release", annotation.Configuration);
Assert.Equal(["-p:RuntimeIdentifier=ios-arm64"], annotation.AdditionalBuildArguments);
}
[Fact]
public void BuildInfoAnnotation_NullableProperties()
{
var annotation = new MauiBuildInfoAnnotation(
"/path/to/project.csproj",
"/path/to",
targetFramework: null);
Assert.Null(annotation.TargetFramework);
Assert.Null(annotation.Configuration);
Assert.Empty(annotation.AdditionalBuildArguments);
}
[Fact]
public async Task CancelQueuedResource_CompletesGracefullyAndDoesNotAcquireSemaphore()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// Hold the semaphore with Android.
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
// Queue MacCatalyst — it should be stuck waiting.
var queued = WaitForStateAsync(env, env.MacCatalyst, "Queued");
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
CancellationToken.None));
await queued;
Assert.False(task2.IsCompleted, "MacCatalyst should be queued.");
// Cancel the queued resource via the subscriber's CancelResource method.
env.CancelResource(env.MacCatalyst.Name);
// The handler re-throws the OCE to prevent DCP from starting the resource.
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => task2.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsType<OperationCanceledException>(ex);
// Semaphore should still be held (count 0) — cancellation of queued resource
// should NOT release the semaphore because it never acquired it.
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount);
// Clean up: complete Android build.
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task StopCommand_QueuedOrBuildingResource_CancelsBuild()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
AddOriginalStopCommand(env.Android);
var eventTask = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
var stopCommand = env.Android.Annotations
.OfType<ResourceCommandAnnotation>()
.Single(a => a.Name == KnownResourceCommands.StopCommand);
Assert.Equal(ResourceCommandState.Enabled, stopCommand.UpdateState(CreateUpdateStateContext(env, "Building")));
var result = await stopCommand.ExecuteCommand(CreateExecuteCommandContext(env));
Assert.True(result.Success);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => eventTask.WaitAsync(TimeSpan.FromSeconds(5)));
var exitedSeen = WaitForStateAsync(env, env.Android, KnownResourceStates.Exited);
await env.NotificationService.PublishUpdateAsync(env.Android, s => s with
{
State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error)
});
await exitedSeen.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task StopCommand_RunningResource_DelegatesToOriginalStopCommand()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
var originalStopInvoked = false;
AddOriginalStopCommand(env.Android, () => originalStopInvoked = true);
env.Subscriber.CompleteBuildImmediately(env.Android);
await env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None);
var stopCommand = env.Android.Annotations
.OfType<ResourceCommandAnnotation>()
.Single(a => a.Name == KnownResourceCommands.StopCommand);
Assert.Equal(ResourceCommandState.Enabled, stopCommand.UpdateState(CreateUpdateStateContext(env, KnownResourceStates.Running)));
var result = await stopCommand.ExecuteCommand(CreateExecuteCommandContext(env));
Assert.True(result.Success);
Assert.True(originalStopInvoked);
}
[Fact]
public async Task CancelBuildingResource_ReleasesSemaphore()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// Start Android build (it will hold the semaphore and wait for TCS).
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
// Android should be building (semaphore held).
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount);
// Cancel via the subscriber — this cancels the CTS, which cancels the TCS via the registration.
env.CancelResource(env.Android.Name);
// The handler re-throws the OCE to prevent DCP from starting the resource.
// TaskCanceledException is a subclass of OperationCanceledException, thrown by TCS.
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => task1.WaitAsync(TimeSpan.FromSeconds(5)));
// Semaphore should be released after the building resource is cancelled.
Assert.Equal(1, annotation.BuildSemaphore.CurrentCount);
}
[Fact]
public async Task ReleaseSemaphoreAfterLaunchAsync_SkipsReplayStateAndReleasesOnStableState()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
await annotation!.BuildSemaphore.WaitAsync();
await env.NotificationService.PublishUpdateAsync(env.Android, s => s with
{
State = new ResourceStateSnapshot("Building", KnownResourceStateStyles.Info)
});
var logger = env.Services.GetRequiredService<ResourceLoggerService>().GetLogger(env.Android);
var subscriber = new MauiBuildQueueEventSubscriber(
env.NotificationService,
env.Services.GetRequiredService<ResourceLoggerService>());
var releaseTask = subscriber.ReleaseSemaphoreAfterLaunchAsync(
env.Android,
annotation.BuildSemaphore,
stateAtCallTime: "Building",
logger,
CancellationToken.None);
Assert.Equal(0, annotation.BuildSemaphore.CurrentCount);
await env.NotificationService.PublishUpdateAsync(env.Android, s => s with
{
State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success)
});
await releaseTask.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, annotation.BuildSemaphore.CurrentCount);
}
[Fact]
public async Task CancelQueuedResource_NextResourceProceeds()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// Hold the semaphore with Android.
var task1 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
await env.Subscriber.WaitForBuildStartedAsync(env.Android);
// Queue MacCatalyst and iOS.
var macCatalystQueued = WaitForStateAsync(env, env.MacCatalyst, "Queued");
var iosSimulatorQueued = WaitForStateAsync(env, env.IOSSimulator, "Queued");
var task2 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.MacCatalyst, env.Services),
CancellationToken.None));
var task3 = Task.Run(() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.IOSSimulator, env.Services),
CancellationToken.None));
await Task.WhenAll(macCatalystQueued, iosSimulatorQueued);
// Cancel MacCatalyst.
env.CancelResource(env.MacCatalyst.Name);
// The handler re-throws the OCE to prevent DCP from starting the resource.
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => task2.WaitAsync(TimeSpan.FromSeconds(5)));
// Complete Android — iOS should proceed (MacCatalyst was cancelled without acquiring semaphore).
env.Subscriber.CompleteBuild(env.Android);
await task1.WaitAsync(TimeSpan.FromSeconds(5));
// iOS should now be building.
await env.Subscriber.WaitForBuildStartedAsync(env.IOSSimulator);
Assert.False(task3.IsCompleted, "iOS should be building now.");
env.Subscriber.CompleteBuild(env.IOSSimulator);
await task3.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task BuildTimeout_ThrowsTimeoutException()
{
await using var env = await BuildQueueTestEnvironment.CreateAsync();
// Set a very short timeout for testing.
env.Subscriber.BuildTimeout = TimeSpan.FromMilliseconds(100);
// Add a MauiBuildInfoAnnotation so RunBuildAsync doesn't skip the build.
env.Android.Annotations.Add(new MauiBuildInfoAnnotation(
"/nonexistent/project.csproj",
"/nonexistent",
"net10.0-android"));
env.Subscriber.UseRealBuild = true;
// The real RunBuildAsync will try to start a dotnet process that either fails
// immediately or gets killed by the timeout. Either results in an exception.
await Assert.ThrowsAnyAsync<Exception>(
() => env.Eventing.PublishAsync(
new BeforeResourceStartedEvent(env.Android, env.Services),
CancellationToken.None));
// Semaphore should be released regardless.
Assert.True(env.Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation));
Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount);
}
private static void AddOriginalStopCommand(IResource resource, Action? onExecute = null)
{
resource.Annotations.Add(new ResourceCommandAnnotation(
name: KnownResourceCommands.StopCommand,
displayName: "Stop",
updateState: _ => ResourceCommandState.Enabled,
executeCommand: _ =>
{
onExecute?.Invoke();
return Task.FromResult(CommandResults.Success());
},
displayDescription: null,
parameter: null,
confirmationMessage: null,
iconName: "Stop",
iconVariant: IconVariant.Filled,
isHighlighted: true));
}
private static UpdateCommandStateContext CreateUpdateStateContext(BuildQueueTestEnvironment env, string? state)
{
return new()
{
ResourceSnapshot = new CustomResourceSnapshot
{
Properties = [],
ResourceType = "maui",
State = state
},
Services = env.Services
};
}
private static ExecuteCommandContext CreateExecuteCommandContext(BuildQueueTestEnvironment env)
{
return new()
{
ResourceName = env.Android.Name,
Services = env.Services,
CancellationToken = CancellationToken.None,
Logger = env.Services.GetRequiredService<ResourceLoggerService>().GetLogger(env.Android),
Arguments = new InteractionInputCollection([])
};
}
private static async Task WaitForStateAsync(BuildQueueTestEnvironment env, IResource resource, string state)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (var evt in env.NotificationService.WatchAsync(cts.Token))
{
if (evt.Resource.Name == resource.Name && evt.Snapshot.State?.Text == state)
{
return;
}
}
}
// ───────────────────────────────────────────────────────────────────
// Test infrastructure
// ───────────────────────────────────────────────────────────────────
/// <summary>
/// A subscriber that overrides <see cref="RunBuildAsync"/> with a controllable
/// <see cref="TaskCompletionSource"/> so tests can decide when (and whether) each
/// resource's build completes. Exposes a <c>WaitForBuildStartedAsync</c> method for
/// deterministic synchronization instead of <c>Task.Delay</c>.
/// </summary>
private sealed class TestableBuildQueueSubscriber(
ResourceNotificationService notificationService,
ResourceLoggerService loggerService) : MauiBuildQueueEventSubscriber(notificationService, loggerService)
{
private readonly ConcurrentDictionary<string, TaskCompletionSource> _buildCompletions = new();
private readonly ConcurrentDictionary<string, TaskCompletionSource> _buildStarted = new();
private readonly ConcurrentDictionary<string, Exception> _buildFailures = new();
/// <summary>When true, delegates to the real <see cref="MauiBuildQueueEventSubscriber.RunBuildAsync"/>.</summary>
public bool UseRealBuild { get; set; }
/// <summary>Waits until <see cref="RunBuildAsync"/> is entered for the given resource.</summary>
public Task WaitForBuildStartedAsync(IResource resource, TimeSpan? timeout = null)
{
return GetOrCreateStarted(resource.Name).Task.WaitAsync(timeout ?? TimeSpan.FromSeconds(30));
}
/// <summary>Completes the build for the given resource, unblocking the event handler.</summary>
public void CompleteBuild(IResource resource)
{
GetOrCreateCompletion(resource.Name).TrySetResult();
}
/// <summary>Pre-registers a resource whose build should complete immediately.</summary>
public void CompleteBuildImmediately(IResource resource)
{
GetOrCreateCompletion(resource.Name).TrySetResult();
}
/// <summary>Pre-registers a resource whose build should fail with an exception.</summary>
public void FailBuild(IResource resource, string message)
{
_buildFailures[resource.Name] = new InvalidOperationException(message);
GetOrCreateCompletion(resource.Name).TrySetResult();
}
/// <summary>Pre-registers a resource whose build should fail with a specific exception.</summary>
public void FailBuildWith(IResource resource, Exception exception)
{
_buildFailures[resource.Name] = exception;
GetOrCreateCompletion(resource.Name).TrySetResult();
}
internal override async Task RunBuildAsync(IResource resource, ILogger logger, CancellationToken cancellationToken)
{
if (UseRealBuild)
{
GetOrCreateStarted(resource.Name).TrySetResult();
await base.RunBuildAsync(resource, logger, cancellationToken).ConfigureAwait(false);
return;
}
var tcs = GetOrCreateCompletion(resource.Name);
try
{
// Signal that the build has started (deterministic sync point for tests).
GetOrCreateStarted(resource.Name).TrySetResult();
using var reg = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
await tcs.Task.ConfigureAwait(false);
if (_buildFailures.TryRemove(resource.Name, out var ex))
{
throw ex;
}
}
finally
{
// Always clean up so the same resource can be restarted.
_buildCompletions.TryRemove(resource.Name, out _);
_buildStarted.TryRemove(resource.Name, out _);
}
}
/// <summary>
/// In tests there is no DCP launch phase, so release the semaphore immediately.
/// </summary>
internal override Task ReleaseSemaphoreAfterLaunchAsync(IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime, ILogger logger, CancellationToken cancellationToken)
{
semaphore.Release();
return Task.CompletedTask;
}
private TaskCompletionSource GetOrCreateCompletion(string name)
{
return _buildCompletions.GetOrAdd(name, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
}
private TaskCompletionSource GetOrCreateStarted(string name)
{
return _buildStarted.GetOrAdd(name, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
}
}
/// <summary>
/// A subscriber that uses the real <see cref="MauiBuildQueueEventSubscriber.RunBuildAsync"/>
/// but overrides <see cref="MauiBuildQueueEventSubscriber.ReleaseSemaphoreAfterLaunchAsync"/>
/// to release immediately since there is no DCP launch phase in tests.
/// </summary>
private sealed class RealBuildQueueSubscriberWithImmediateRelease(
ResourceNotificationService notificationService,
ResourceLoggerService loggerService) : MauiBuildQueueEventSubscriber(notificationService, loggerService)
{
internal override Task ReleaseSemaphoreAfterLaunchAsync(IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime, ILogger logger, CancellationToken cancellationToken)
{
semaphore.Release();
return Task.CompletedTask;
}
}
/// <summary>
/// Test environment that creates resources manually and registers only the
/// <see cref="TestableBuildQueueSubscriber"/>, avoiding the Android/iOS
/// environment subscribers that require services unavailable in unit tests.
/// </summary>
private sealed class BuildQueueTestEnvironment : IAsyncDisposable
{
public required DistributedApplication App { get; init; }
public required MauiProjectResource Parent { get; init; }
public required MauiAndroidEmulatorResource Android { get; init; }
public required MauiMacCatalystPlatformResource MacCatalyst { get; init; }
public required MauiiOSSimulatorResource IOSSimulator { get; init; }
public required TestableBuildQueueSubscriber Subscriber { get; init; }
public MauiAndroidEmulatorResource? Android2 { get; init; }
public IServiceProvider Services => App.Services;
public IDistributedApplicationEventing Eventing => App.Services.GetRequiredService<IDistributedApplicationEventing>();
public ResourceNotificationService NotificationService => App.Services.GetRequiredService<ResourceNotificationService>();
/// <summary>Cancels a resource via the annotation's CancelResource method.</summary>
public bool CancelResource(string resourceName)
{
Parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var annotation);
return annotation!.CancelResource(resourceName);
}
public static async Task<BuildQueueTestEnvironment> CreateAsync()
{
var appBuilder = DistributedApplication.CreateBuilder();
var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj");
parent.Annotations.Add(new MauiBuildQueueAnnotation());
appBuilder.CreateResourceBuilder(parent);
var android = new MauiAndroidEmulatorResource("android", parent);
appBuilder.AddResource(android);
var macCatalyst = new MauiMacCatalystPlatformResource("maccatalyst", parent);
appBuilder.AddResource(macCatalyst);
var iosSimulator = new MauiiOSSimulatorResource("ios-simulator", parent);
appBuilder.AddResource(iosSimulator);
var app = appBuilder.Build();
var subscriber = await InitializeSubscriberAsync(app);
return new BuildQueueTestEnvironment
{
App = app,
Parent = parent,
Android = android,
MacCatalyst = macCatalyst,
IOSSimulator = iosSimulator,
Subscriber = subscriber
};
}
public static async Task<BuildQueueTestEnvironment> CreateWithTwoProjectsAsync()
{
var appBuilder = DistributedApplication.CreateBuilder();
var parent1 = new MauiProjectResource("mauiapp1", "/fake/path1.csproj");
parent1.Annotations.Add(new MauiBuildQueueAnnotation());
appBuilder.CreateResourceBuilder(parent1);
var android1 = new MauiAndroidEmulatorResource("android1", parent1);
appBuilder.AddResource(android1);
var parent2 = new MauiProjectResource("mauiapp2", "/fake/path2.csproj");
parent2.Annotations.Add(new MauiBuildQueueAnnotation());
appBuilder.CreateResourceBuilder(parent2);
var android2 = new MauiAndroidEmulatorResource("android2", parent2);
appBuilder.AddResource(android2);
var macCatalyst = new MauiMacCatalystPlatformResource("maccatalyst", parent1);
appBuilder.AddResource(macCatalyst);
var iosSimulator = new MauiiOSSimulatorResource("ios-simulator", parent1);
appBuilder.AddResource(iosSimulator);
var app = appBuilder.Build();
var subscriber = await InitializeSubscriberAsync(app);
return new BuildQueueTestEnvironment
{
App = app,
Parent = parent1,
Android = android1,
MacCatalyst = macCatalyst,
IOSSimulator = iosSimulator,
Android2 = android2,
Subscriber = subscriber
};
}
private static async Task<TestableBuildQueueSubscriber> InitializeSubscriberAsync(DistributedApplication app)
{
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
var loggerService = app.Services.GetRequiredService<ResourceLoggerService>();
var eventing = app.Services.GetRequiredService<IDistributedApplicationEventing>();
var execContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
var subscriber = new TestableBuildQueueSubscriber(notificationService, loggerService);
await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None);
return subscriber;
}
public async ValueTask DisposeAsync()
{
await App.DisposeAsync();
}
}
}