forked from Manuel/meeting-assistant
ci: consolidate platform pipelines and test prerequisites on macOS support
This commit is contained in:
1 parent
1b19b08f2e
commit
728e66dd72
76 files changed
+7477
-239
No files matched your search
@@ -34,7 +34,7 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
public async Task NamedProfileEndpointPassesProfileToConfiguredProvider()
|
||||
{
|
||||
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory();
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
await using var factory = new IsolatedApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
@@ -104,7 +104,7 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
public async Task SpeakerIdentityMergeDiagnosticEndpointUsesRecentDaysParameter()
|
||||
{
|
||||
var mergeService = new CapturingSpeakerIdentityMergeService();
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
await using var factory = new IsolatedApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
@@ -140,7 +140,7 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
|
||||
where TProvider : class, IStreamingTranscriptionProvider
|
||||
{
|
||||
return new WebApplicationFactory<Program>()
|
||||
return new IsolatedApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
@@ -267,4 +267,3 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,9 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
speechConfig.GetProperty(PropertyId.SpeechServiceResponse_PostProcessingOption)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[WindowsFact("per-user environment variables")]
|
||||
public void ResolveKeyFallsBackToUserEnvironment()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
#if !WINDOWS
|
||||
public sealed class DesktopCommunicationEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PendingRequestCanBeReadThroughDesktopEndpoint()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var requests = factory.Services.GetRequiredService<DesktopUserRequestStore>();
|
||||
var registration = requests.Add(new DesktopUserRequestSpec(
|
||||
"agent-approval", "Project association", "Allow access to project Apollo?",
|
||||
TimeSpan.FromMinutes(10), [new("yes", "Yes"), new("no", "No")]), CancellationToken.None);
|
||||
|
||||
using var response = await client.GetAsync("/desktop/requests");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var request = Assert.Single((await response.Content.ReadFromJsonAsync<DesktopUserRequest[]>())!);
|
||||
Assert.Equal(registration.Id, request.Id);
|
||||
Assert.Equal("Allow access to project Apollo?", request.Message);
|
||||
Assert.Equal(new[] { "yes", "no" }, request.Actions.Select(action => action.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfferedHttpResponseDeliversOneDecisionAndRejectsDuplicate()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var requests = factory.Services.GetRequiredService<DesktopUserRequestStore>();
|
||||
var calls = 0;
|
||||
var registration = requests.Add(new DesktopUserRequestSpec(
|
||||
"agent-approval", "Approval", "Apply proposed change?", TimeSpan.FromMinutes(10),
|
||||
[new("yes", "Yes"), new("no", "No")]), CancellationToken.None,
|
||||
(_, _) => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
|
||||
var path = $"/desktop/requests/{registration.Id}/respond";
|
||||
|
||||
using var accepted = await client.PostAsJsonAsync(path, new { action = "yes" });
|
||||
using var duplicate = await client.PostAsJsonAsync(path, new { action = "yes" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Gone, duplicate.StatusCode);
|
||||
Assert.Equal("yes", await registration.Decision);
|
||||
Assert.Equal(1, calls);
|
||||
Assert.Empty((await client.GetFromJsonAsync<DesktopUserRequest[]>("/desktop/requests"))!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedWindowAndRepeatedSnapshotsKeepOneIndependentPrompt()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var sessions = factory.Services.GetRequiredService<MacOsWorkflowRulesEditorSessions>();
|
||||
sessions.OpenWithPrompt("Add the requested workflow scenario in full.");
|
||||
|
||||
using var response = await client.GetAsync("/desktop/agent-windows");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var window = Assert.Single((await response.Content.ReadFromJsonAsync<MacOsAgentWindowRequest[]>())!);
|
||||
Assert.EndsWith($"?session={window.Id}", window.Url);
|
||||
var path = $"/diagnostics/settings-and-logs/chat?session={window.Id}";
|
||||
MacOsWorkflowRulesEditorChatResponse? first = null;
|
||||
for (var attempt = 0; attempt < 100; attempt++)
|
||||
{
|
||||
first = await client.GetFromJsonAsync<MacOsWorkflowRulesEditorChatResponse>(path);
|
||||
if (!first!.IsThinking && first.Messages.Count > 0) break;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
Assert.False(first!.IsThinking);
|
||||
var second = await client.GetFromJsonAsync<MacOsWorkflowRulesEditorChatResponse>(path);
|
||||
Assert.Equal("Add the requested workflow scenario in full.", first.Messages[0].Content);
|
||||
Assert.Equal(first.Messages, second!.Messages);
|
||||
Assert.Single(factory.Services.GetRequiredService<EndpointVerificationPipeline>().Calls);
|
||||
Assert.Empty((await client.GetFromJsonAsync<MacOsWorkflowRulesEditorChatResponse>(
|
||||
"/diagnostics/settings-and-logs/chat"))!.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownChatSessionCannotSendIntoTheDefaultConversation()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/diagnostics/settings-and-logs/chat?session=unknown-session", new { message = "Do not route this elsewhere" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Empty(factory.Services.GetRequiredService<EndpointVerificationPipeline>().Calls);
|
||||
Assert.Empty((await client.GetFromJsonAsync<MacOsWorkflowRulesEditorChatResponse>(
|
||||
"/diagnostics/settings-and-logs/chat"))!.Messages);
|
||||
}
|
||||
|
||||
[MacOsFact]
|
||||
public async Task MacOsCompositionWaitsForApprovalAndUsesTheDesktopDecision()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var origin = new CancellationTokenSource();
|
||||
var approval = factory.Services.GetRequiredService<AgentApprovalService>();
|
||||
var requests = factory.Services.GetRequiredService<DesktopUserRequestStore>();
|
||||
var decision = approval.ConfirmAsync("Test approval", "Isolated composition check", origin.Token);
|
||||
try
|
||||
{
|
||||
var pending = Assert.Single(requests.List());
|
||||
Assert.False(decision.IsCompleted);
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"/desktop/requests/{pending.Id}/respond", new { action = "no" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(await decision.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.IsType<MacOsMeetingInactivityPromptService>(
|
||||
factory.Services.GetRequiredService<MeetingAssistant.Recording.IMeetingInactivityPromptService>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
origin.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
internal static WebApplicationFactory<Program> CreateFactory()
|
||||
{
|
||||
return new IsolatedApplicationFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
|
||||
["MeetingAssistant:CalendarRecordingPrompts:Enabled"] = "false"
|
||||
}));
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IWorkflowRulesEditorChatPipeline>();
|
||||
services.AddSingleton<EndpointVerificationPipeline>();
|
||||
services.AddSingleton<IWorkflowRulesEditorChatPipeline>(provider =>
|
||||
provider.GetRequiredService<EndpointVerificationPipeline>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EndpointVerificationPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage, CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
Calls.Add(userMessage);
|
||||
return Task.FromResult(new WorkflowRulesEditorChatResult("Verification reply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,198 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class DesktopUserRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task OfferedDecisionIsListedAndDeliveredOnce()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
var registration = store.Add(Approval(), CancellationToken.None);
|
||||
var request = Assert.Single(store.List());
|
||||
Assert.Equal(registration.Id, request.Id);
|
||||
Assert.Equal("Associate Alpha?", request.Title);
|
||||
Assert.Equal(["yes", "no"], request.Actions.Select(action => action.Id));
|
||||
Assert.False(registration.Decision.IsCompleted);
|
||||
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, "yes"));
|
||||
Assert.Equal("yes", await registration.Decision);
|
||||
Assert.Empty(store.List());
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(request.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsupportedActionLeavesDecisionPending()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
var registration = store.Add(Approval(), CancellationToken.None);
|
||||
Assert.Equal(DesktopUserRequestRespondResult.InvalidAction, await store.RespondAsync(registration.Id, "delete"));
|
||||
Assert.Single(store.List());
|
||||
Assert.False(registration.Decision.IsCompleted);
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(registration.Id, "no"));
|
||||
Assert.Equal("no", await registration.Decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledOriginRemovesDecisionAndRejectsLateResponse()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
using var origin = new CancellationTokenSource();
|
||||
var invoked = false;
|
||||
var registration = store.Add(Approval(), origin.Token, (_, _) =>
|
||||
{
|
||||
invoked = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await origin.CancelAsync();
|
||||
Assert.Empty(store.List());
|
||||
Assert.Null(await registration.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadlineResolvesUnansweredDecisionWithoutPolling()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var clock = new ManualDesktopRequestTimeProvider();
|
||||
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
var registration = store.Add(Approval(), CancellationToken.None);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-10-03T10:10:00Z"), Assert.Single(store.List()).ExpiresAt);
|
||||
clock.Advance(TimeSpan.FromMinutes(10));
|
||||
Assert.True(registration.Decision.IsCompleted);
|
||||
Assert.Null(await registration.Decision);
|
||||
Assert.Empty(store.List());
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KindWithdrawalPreservesOtherQuestionsAndShutdownRemovesThem()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
var approval = store.Add(Approval(), CancellationToken.None);
|
||||
var warning = store.Add(Approval() with { Kind = "meeting-inactivity" }, CancellationToken.None);
|
||||
store.DismissKind("meeting-inactivity");
|
||||
Assert.Equal(approval.Id, Assert.Single(store.List()).Id);
|
||||
Assert.Null(await warning.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(warning.Id, "yes"));
|
||||
lifetime.StopApplication();
|
||||
Assert.Empty(store.List());
|
||||
Assert.Null(await approval.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(approval.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedActionIsConsumedAndReportedWithoutReplayingWork()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
var registration = store.Add(Approval(), CancellationToken.None,
|
||||
(_, _) => throw new InvalidOperationException("Test action could not finish."));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Failed, await store.RespondAsync(registration.Id, "yes"));
|
||||
Assert.Null(await registration.Decision);
|
||||
Assert.Empty(store.List());
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingListExcludesDeadlinesBeforeTimerDispatch()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var clock = new ManualDesktopRequestTimeProvider();
|
||||
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
var registration = store.Add(Approval(), CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromMinutes(10), fireTimers: false);
|
||||
Assert.Empty(store.List());
|
||||
Assert.Null(await registration.Decision);
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimultaneousPresentationsUseTheOriginLifetimeAndPerformOneAction()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var origin = new CancellationTokenSource();
|
||||
using var store = CreateStore(lifetime);
|
||||
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var actionCount = 0;
|
||||
CancellationToken observedToken = default;
|
||||
var registration = store.Add(Approval(), origin.Token, async (_, token) =>
|
||||
{
|
||||
Interlocked.Increment(ref actionCount);
|
||||
observedToken = token;
|
||||
started.TrySetResult();
|
||||
await finish.Task.WaitAsync(token);
|
||||
});
|
||||
var firstResponse = store.RespondAsync(registration.Id, "yes");
|
||||
await started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "no"));
|
||||
Assert.Equal(origin.Token, observedToken);
|
||||
finish.SetResult();
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await firstResponse);
|
||||
Assert.Equal("yes", await registration.Decision);
|
||||
Assert.Equal(1, actionCount);
|
||||
}
|
||||
|
||||
private static DesktopUserRequestSpec Approval() => new(
|
||||
"agent-approval", "Associate Alpha?", "The meeting discusses Alpha.", TimeSpan.FromMinutes(10),
|
||||
[new("yes", "Yes"), new("no", "No")]);
|
||||
|
||||
private static DesktopUserRequestStore CreateStore(TestApplicationLifetime lifetime) => new(
|
||||
TimeProvider.System, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
}
|
||||
|
||||
internal sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
public CancellationToken ApplicationStarted => CancellationToken.None;
|
||||
public CancellationToken ApplicationStopping => stopping.Token;
|
||||
public CancellationToken ApplicationStopped => stopping.Token;
|
||||
public void StopApplication() => stopping.Cancel();
|
||||
public void Dispose() => stopping.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class ManualDesktopRequestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset now = DateTimeOffset.Parse("2026-10-03T10:00:00Z");
|
||||
private readonly List<ManualTimer> timers = [];
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ManualTimer(this, callback, state);
|
||||
timers.Add(timer);
|
||||
timer.Change(dueTime, period);
|
||||
return timer;
|
||||
}
|
||||
public void Advance(TimeSpan elapsed, bool fireTimers = true)
|
||||
{
|
||||
now += elapsed;
|
||||
if (!fireTimers) return;
|
||||
foreach (var timer in timers.ToArray()) timer.FireIfDue();
|
||||
}
|
||||
private sealed class ManualTimer(ManualDesktopRequestTimeProvider owner, TimerCallback callback, object? state) : ITimer
|
||||
{
|
||||
private DateTimeOffset due;
|
||||
private bool disposed;
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
due = dueTime == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : owner.now + dueTime;
|
||||
return !disposed;
|
||||
}
|
||||
public void FireIfDue()
|
||||
{
|
||||
if (disposed || due > owner.now) return;
|
||||
due = DateTimeOffset.MaxValue;
|
||||
callback(state);
|
||||
}
|
||||
public void Dispose() => disposed = true;
|
||||
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public sealed class HealthEndpointTests : IClassFixture<IsolatedApplicationFactory>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> factory;
|
||||
|
||||
public HealthEndpointTests(WebApplicationFactory<Program> factory)
|
||||
public HealthEndpointTests(IsolatedApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
@@ -55,6 +55,7 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
|
||||
Assert.Equal(HostOperatingSystem.DetectCurrent(), first);
|
||||
}
|
||||
|
||||
#if !WINDOWS
|
||||
[Fact]
|
||||
public async Task NeutralBuildReportsSpeakerPlaybackUnavailable()
|
||||
{
|
||||
@@ -65,6 +66,7 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
|
||||
Assert.Contains("Windows build", result);
|
||||
Assert.DoesNotContain("Queued", result);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationStartupDeletesStaleTemporaryRecordings()
|
||||
@@ -75,6 +77,7 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
services.RemoveAll<IRecordedAudioStore>();
|
||||
services.AddSingleton<IRecordedAudioStore, StartupCleanupRecordedAudioStore>();
|
||||
services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
||||
});
|
||||
});
|
||||
using var client = cleanupFactory.CreateClient();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class IsolatedApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Endpoint tests retain production composition but opt into each background activity they exercise.
|
||||
foreach (var descriptor in services.Where(descriptor =>
|
||||
descriptor.ServiceType == typeof(IHostedService) &&
|
||||
(descriptor.ImplementationType?.Assembly == typeof(Program).Assembly ||
|
||||
descriptor.ImplementationFactory is not null)).ToArray())
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Workflow;
|
||||
using MeetingAssistant.Notifications;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -10,33 +8,17 @@ namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsDesktopControlManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void AutomaticWorkflowChangeCannotSilentlyLoseItsPromptOnMacOs()
|
||||
{
|
||||
IWorkflowRulesEditorWindowService service = new MacOsWorkflowRulesEditorWindowService(
|
||||
Options.Create(new MeetingAssistantOptions()));
|
||||
|
||||
Assert.Throws<PlatformNotSupportedException>(() => service.ShowWithPrompt("Update meeting rules"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsApplicationDeclinesUnavailableAutomaticApprovals()
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
var approval = factory.Services.GetRequiredService<AgentApprovalService>();
|
||||
|
||||
Assert.False(await approval.ConfirmAsync("Test approval", "Do not apply a change", CancellationToken.None));
|
||||
}
|
||||
|
||||
#if !WINDOWS
|
||||
[Fact]
|
||||
public void MacOsApplicationRegistersARealInteractiveAgentWindow()
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
using var factory = new IsolatedApplicationFactory();
|
||||
|
||||
var service = factory.Services.GetRequiredService<IWorkflowRulesEditorWindowService>();
|
||||
|
||||
Assert.IsType<MacOsWorkflowRulesEditorWindowService>(service);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void ManifestPreservesConfiguredWindowsEquivalentShortcutsAndControlEndpoints()
|
||||
|
||||
@@ -4,9 +4,13 @@ public sealed class MacOsFactAttribute : FactAttribute
|
||||
{
|
||||
public MacOsFactAttribute()
|
||||
{
|
||||
#if WINDOWS
|
||||
Skip = "Requires the portable macOS target, native macOS helpers and Apple system tools; run net10.0 on macOS.";
|
||||
#else
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
Skip = "Requires native macOS helpers and Apple system tools; run on macOS.";
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -164,7 +163,7 @@ public sealed class MacOsMeetingAudioSourceTests
|
||||
microphonePcm: Pcm16(2_000),
|
||||
systemPcm: Pcm16(10_000));
|
||||
var speechPipelines = new CapturingSpeechRecognitionPipelineFactory();
|
||||
var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||
var factory = new IsolatedApplicationFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
@@ -184,10 +183,7 @@ public sealed class MacOsMeetingAudioSourceTests
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// The endpoint fixture supplies its own transcription pipeline and does not warm external pyannote models.
|
||||
services.Remove(services.Single(descriptor =>
|
||||
descriptor.ServiceType == typeof(IHostedService) &&
|
||||
descriptor.ImplementationType == typeof(PyannoteDiarizationWarmupHostedService)));
|
||||
services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||
services.RemoveAll<IMeetingAudioSource>();
|
||||
services.RemoveAll<IMacOsAudioCaptureProcessFactory>();
|
||||
PortableMeetingAudioRegistration.Add(services, isMacOs: true);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
@@ -18,6 +19,10 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
{
|
||||
var macServices = new ServiceCollection().AddLogging();
|
||||
var unsupportedServices = new ServiceCollection().AddLogging();
|
||||
using var host = new HostBuilder().Build();
|
||||
macServices.AddSingleton(TimeProvider.System);
|
||||
macServices.AddSingleton(host.Services.GetRequiredService<IHostApplicationLifetime>());
|
||||
macServices.AddSingleton<DesktopUserRequestStore>();
|
||||
|
||||
MacOsMeetingIntegrationRegistration.Add(macServices, isMacOs: true);
|
||||
MacOsMeetingIntegrationRegistration.Add(unsupportedServices, isMacOs: false);
|
||||
@@ -96,39 +101,20 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsPromptAndScreenshotUseNativeClientResults()
|
||||
public async Task MacOsScreenshotAndCropUseNativeClientResults()
|
||||
{
|
||||
var screenshot = new ActiveWindowScreenshot([137, 80, 78, 71], "Meeting deck");
|
||||
var client = new StubMacOsDesktopIntegrationClient(
|
||||
screenshot: screenshot,
|
||||
promptResponse: MeetingStartPromptResponse.Record);
|
||||
IMeetingStartPromptService prompt = new MacOsMeetingStartPromptService(
|
||||
client,
|
||||
NullLogger<MacOsMeetingStartPromptService>.Instance);
|
||||
IActiveWindowScreenshotCapture capture = client;
|
||||
IScreenshotImageCropper cropper = client;
|
||||
MeetingStartPromptResponse? callbackResponse = null;
|
||||
var request = new MeetingStartPromptRequest(new CalendarMeeting(
|
||||
"event-1",
|
||||
"Project sync",
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now.AddHours(1)));
|
||||
|
||||
await prompt.ShowPromptAsync(
|
||||
request,
|
||||
(response, _) =>
|
||||
{
|
||||
callbackResponse = response;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
var captured = await capture.CaptureAsync(CancellationToken.None);
|
||||
var croppedPath = await cropper.SaveCroppedScreenshotAsync(
|
||||
"meeting.png",
|
||||
new ScreenshotCropCoordinates(1, 2, 3, 4),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MeetingStartPromptResponse.Record, callbackResponse);
|
||||
Assert.Equal(screenshot, captured);
|
||||
Assert.Equal("meeting.png.cropped.png", croppedPath);
|
||||
}
|
||||
@@ -136,7 +122,7 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
[MacOsFact]
|
||||
public async Task MacOsCapabilityEndpointReportsEnabledRealProviders()
|
||||
{
|
||||
await using var factory = new WebApplicationFactory<Program>();
|
||||
await using var factory = new IsolatedApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var report = await client.GetFromJsonAsync<PlatformCapabilityReport>(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsUserPromptTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("yes", true)]
|
||||
[InlineData("no", false)]
|
||||
public async Task ApprovalWaitsForAnExplicitDesktopDecision(string action, bool approved)
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
IAgentApprovalPromptService prompts = new MacOsAgentApprovalPromptService(store);
|
||||
var decision = prompts.ConfirmAsync(new AgentApprovalPrompt("Project requested", "Associate Alpha?",
|
||||
TimeSpan.FromMinutes(10)), CancellationToken.None);
|
||||
Assert.False(decision.IsCompleted);
|
||||
var request = Assert.Single(store.List());
|
||||
Assert.Equal("agent-approval", request.Kind);
|
||||
Assert.Equal(["yes", "no"], request.Actions.Select(item => item.Id));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, action));
|
||||
Assert.Equal(approved, await decision);
|
||||
Assert.Empty(store.List());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivityPromptReturnsImmediatelyAndOffersPauseWithOriginLifetime()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var origin = new CancellationTokenSource();
|
||||
var clock = new ManualDesktopRequestTimeProvider();
|
||||
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
IMeetingInactivityPromptService prompts = new MacOsMeetingInactivityPromptService(store);
|
||||
MeetingInactivityPromptResponse? received = null;
|
||||
CancellationToken receivedToken = default;
|
||||
var showing = prompts.ShowStopPromptAsync(
|
||||
new MeetingInactivityPromptRequest(TimeSpan.FromMinutes(2), clock.GetUtcNow(), 1, TimeSpan.FromMinutes(2)),
|
||||
(response, token) =>
|
||||
{
|
||||
received = response;
|
||||
receivedToken = token;
|
||||
return Task.CompletedTask;
|
||||
}, origin.Token);
|
||||
Assert.True(showing.IsCompletedSuccessfully);
|
||||
var request = Assert.Single(store.List());
|
||||
Assert.Equal("meeting-inactivity", request.Kind);
|
||||
Assert.Equal(["stop", "continue", "pause"], request.Actions.Select(item => item.Id));
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-10-03T10:01:00Z"), request.ExpiresAt);
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, "pause"));
|
||||
Assert.Equal(MeetingInactivityPromptResponse.Pause, received);
|
||||
Assert.Equal(origin.Token, receivedToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "attach-metadata", MeetingStartPromptResponse.AttachMetadataToCurrentMeeting)]
|
||||
[InlineData(false, "skip", MeetingStartPromptResponse.Skip)]
|
||||
public async Task CalendarPromptReturnsImmediatelyAndPreservesOfferedActions(bool canAttach, string action, MeetingStartPromptResponse expected)
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var clock = new ManualDesktopRequestTimeProvider();
|
||||
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
IMeetingStartPromptService prompts = new MacOsMeetingStartPromptService(store, clock);
|
||||
var meeting = new CalendarMeeting("event-1", "Alpha planning", clock.GetUtcNow(), clock.GetUtcNow().AddMinutes(2));
|
||||
MeetingStartPromptResponse? received = null;
|
||||
var showing = prompts.ShowPromptAsync(new MeetingStartPromptRequest(meeting, canAttach),
|
||||
(response, _) => { received = response; return Task.CompletedTask; }, CancellationToken.None);
|
||||
Assert.True(showing.IsCompletedSuccessfully);
|
||||
var request = Assert.Single(store.List());
|
||||
Assert.Equal("meeting-start", request.Kind);
|
||||
Assert.Contains("Alpha planning", request.Message);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-10-03T10:02:00Z"), request.ExpiresAt);
|
||||
Assert.Equal(canAttach, request.Actions.Any(item => item.Id == "attach-metadata"));
|
||||
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, action));
|
||||
Assert.Equal(expected, received);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ApprovalExpirationOrOriginCancellationNeverGrantsConsent(bool cancelOrigin)
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var origin = new CancellationTokenSource();
|
||||
var clock = new ManualDesktopRequestTimeProvider();
|
||||
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
IAgentApprovalPromptService prompts = new MacOsAgentApprovalPromptService(store);
|
||||
var decision = prompts.ConfirmAsync(new AgentApprovalPrompt("Workflow requested", "Update matching rule?",
|
||||
TimeSpan.FromMinutes(10)), origin.Token);
|
||||
var request = Assert.Single(store.List());
|
||||
if (cancelOrigin)
|
||||
{
|
||||
await origin.CancelAsync();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => decision);
|
||||
}
|
||||
else
|
||||
{
|
||||
clock.Advance(TimeSpan.FromMinutes(10));
|
||||
Assert.False(await decision);
|
||||
}
|
||||
Assert.Empty(store.List());
|
||||
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(request.Id, "yes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DismissingInactivityLeavesUnrelatedApprovalPending()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
using var store = CreateStore(lifetime);
|
||||
IAgentApprovalPromptService approvals = new MacOsAgentApprovalPromptService(store);
|
||||
var decision = approvals.ConfirmAsync(new AgentApprovalPrompt("Project requested", "Associate Alpha?",
|
||||
TimeSpan.FromMinutes(10)), CancellationToken.None);
|
||||
var approval = Assert.Single(store.List());
|
||||
IMeetingInactivityPromptService inactivity = new MacOsMeetingInactivityPromptService(store);
|
||||
await inactivity.ShowStopPromptAsync(new MeetingInactivityPromptRequest(TimeSpan.FromMinutes(2),
|
||||
DateTimeOffset.UtcNow, 1, TimeSpan.FromMinutes(2)), (_, _) => Task.CompletedTask, CancellationToken.None);
|
||||
Assert.Equal(2, store.List().Count);
|
||||
await inactivity.DismissAllAsync(CancellationToken.None);
|
||||
Assert.Equal(approval.Id, Assert.Single(store.List()).Id);
|
||||
await store.RespondAsync(approval.Id, "no");
|
||||
Assert.False(await decision);
|
||||
}
|
||||
|
||||
private static DesktopUserRequestStore CreateStore(TestApplicationLifetime lifetime) => new(
|
||||
TimeProvider.System, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ApprovedWorkflowStartsOnceInOwnSessionWithoutReplacingDefaultConversation()
|
||||
{
|
||||
var pipeline = new ReplyPipeline();
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(pipeline, lifetime);
|
||||
Assert.True(sessions.TryGetSession(null, out var normal));
|
||||
await normal.SendAsync("My existing conversation", CancellationToken.None);
|
||||
const string prompt = "Given Alpha in the title,\nwhen metadata arrives, then associate project Alpha.";
|
||||
|
||||
sessions.OpenWithPrompt(prompt);
|
||||
|
||||
var window = Assert.Single(sessions.GetAgentWindows());
|
||||
Assert.EndsWith($"/diagnostics/settings-and-logs?session={window.Id}", window.Url);
|
||||
Assert.True(sessions.TryGetSession(window.Id, out var requested));
|
||||
var snapshot = await WaitForCompletionAsync(requested);
|
||||
Assert.Equal(
|
||||
[new MacOsWorkflowRulesEditorMessage("user", prompt), new MacOsWorkflowRulesEditorMessage("agent", "Agent response.")],
|
||||
snapshot.Messages);
|
||||
for (var index = 0; index < 3; index++)
|
||||
{
|
||||
Assert.Equal(snapshot.Messages, requested.GetSnapshot().Messages);
|
||||
Assert.False(requested.GetSnapshot().IsThinking);
|
||||
Assert.Single(sessions.GetAgentWindows());
|
||||
}
|
||||
Assert.Equal(
|
||||
[new MacOsWorkflowRulesEditorMessage("user", "My existing conversation"), new MacOsWorkflowRulesEditorMessage("agent", "Agent response.")],
|
||||
normal.GetSnapshot().Messages);
|
||||
Assert.NotSame(normal, requested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingAResponseConnectionKeepsAcceptedChatWorkingAndSnapshotsRemainReadable()
|
||||
{
|
||||
var pipeline = new BlockingPipeline();
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(pipeline, lifetime);
|
||||
Assert.True(sessions.TryGetSession(null, out var session));
|
||||
using var responseConnection = new CancellationTokenSource();
|
||||
var response = session.SendAsync("Continue the approved change", responseConnection.Token);
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var pending = session.GetSnapshot();
|
||||
Assert.True(pending.IsThinking);
|
||||
Assert.Equal([new MacOsWorkflowRulesEditorMessage("user", "Continue the approved change")], pending.Messages);
|
||||
|
||||
responseConnection.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => response);
|
||||
|
||||
Assert.True(session.GetSnapshot().IsThinking);
|
||||
pipeline.Release.TrySetResult();
|
||||
var completed = await WaitForCompletionAsync(session);
|
||||
Assert.False(completed.IsThinking);
|
||||
Assert.Equal("Agent response.", completed.Messages.Last().Content);
|
||||
Assert.Single(pending.Messages);
|
||||
Assert.True(pending.IsThinking);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedWorkflowWindowOpensWithoutWaitingForItsAgentResponse()
|
||||
{
|
||||
var pipeline = new BlockingPipeline();
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(pipeline, lifetime);
|
||||
IWorkflowRulesEditorWindowService editor = new MacOsWorkflowRulesEditorWindowService(
|
||||
Options.Create(new MeetingAssistantOptions()), () => sessions);
|
||||
try
|
||||
{
|
||||
await Task.Run(() => editor.ShowWithPrompt("Apply the approved workflow scenario."))
|
||||
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var window = Assert.Single(sessions.GetAgentWindows());
|
||||
Assert.True(sessions.TryGetSession(window.Id, out var session));
|
||||
Assert.True(session.GetSnapshot().IsThinking);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipeline.Release.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructingTheWindowServiceDoesNotStartOrResolveAnAgentConversation()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(new ReplyPipeline(), lifetime);
|
||||
var resolutions = 0;
|
||||
IWorkflowRulesEditorWindowService editor = new MacOsWorkflowRulesEditorWindowService(
|
||||
Options.Create(new MeetingAssistantOptions()), () =>
|
||||
{
|
||||
resolutions++;
|
||||
return sessions;
|
||||
});
|
||||
|
||||
Assert.Equal(0, resolutions);
|
||||
Assert.Empty(sessions.GetAgentWindows());
|
||||
|
||||
editor.ShowWithPrompt("An explicitly approved scenario.");
|
||||
|
||||
Assert.Equal(1, resolutions);
|
||||
Assert.Single(sessions.GetAgentWindows());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoApprovedWorkflowsAndTheirFollowUpsRemainIndependent()
|
||||
{
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(new ReplyPipeline(), lifetime);
|
||||
sessions.OpenWithPrompt("First approved scenario.");
|
||||
sessions.OpenWithPrompt("Second approved scenario.");
|
||||
var windows = sessions.GetAgentWindows();
|
||||
Assert.Equal(2, windows.Count);
|
||||
Assert.Equal(2, windows.Select(window => window.Id).Distinct().Count());
|
||||
var conversations = new List<MacOsWorkflowRulesEditorSession>();
|
||||
foreach (var window in windows)
|
||||
{
|
||||
Assert.True(sessions.TryGetSession(window.Id, out var conversation));
|
||||
conversations.Add(conversation);
|
||||
}
|
||||
var snapshots = await Task.WhenAll(conversations.Select(WaitForCompletionAsync));
|
||||
Assert.Equal(
|
||||
["First approved scenario.", "Second approved scenario."],
|
||||
snapshots.Select(snapshot => snapshot.Messages[0].Content).Order());
|
||||
|
||||
var updated = await conversations[0].SendAsync("A follow-up in just this conversation.", CancellationToken.None);
|
||||
|
||||
Assert.Equal(4, updated.Messages.Count);
|
||||
Assert.Equal(2, conversations[1].GetSnapshot().Messages.Count);
|
||||
Assert.True(sessions.TryGetSession(null, out var normal));
|
||||
Assert.Empty(normal.GetSnapshot().Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationShutdownCancelsBackgroundConversationAndPreventsNewWindows()
|
||||
{
|
||||
var pipeline = new BlockingPipeline();
|
||||
using var lifetime = new TestApplicationLifetime();
|
||||
var sessions = CreateSessions(pipeline, lifetime);
|
||||
sessions.OpenWithPrompt("Apply the approved scenario.");
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var window = Assert.Single(sessions.GetAgentWindows());
|
||||
Assert.True(sessions.TryGetSession(window.Id, out var conversation));
|
||||
|
||||
lifetime.StopApplication();
|
||||
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
while (conversation.GetSnapshot().IsThinking) await Task.Delay(10, deadline.Token);
|
||||
Assert.Empty(sessions.GetAgentWindows());
|
||||
Assert.Single(conversation.GetSnapshot().Messages);
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => sessions.OpenWithPrompt("A stale approval."));
|
||||
}
|
||||
|
||||
private static MacOsWorkflowRulesEditorSessions CreateSessions(
|
||||
IWorkflowRulesEditorChatPipeline pipeline,
|
||||
IHostApplicationLifetime lifetime) => new(
|
||||
pipeline,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
lifetime,
|
||||
NullLogger<MacOsWorkflowRulesEditorSessions>.Instance);
|
||||
|
||||
private static async Task<MacOsWorkflowRulesEditorChatResponse> WaitForCompletionAsync(
|
||||
MacOsWorkflowRulesEditorSession session)
|
||||
{
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
while (true)
|
||||
{
|
||||
var snapshot = session.GetSnapshot();
|
||||
if (snapshot.Messages.Count >= 2 && !snapshot.IsThinking) return snapshot;
|
||||
await Task.Delay(10, deadline.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReplyPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
public Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null) =>
|
||||
Task.FromResult(new WorkflowRulesEditorChatResult("Agent response."));
|
||||
}
|
||||
|
||||
private sealed class BlockingPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
Started.TrySetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult("Agent response.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
public CancellationToken ApplicationStarted => CancellationToken.None;
|
||||
public CancellationToken ApplicationStopping => stopping.Token;
|
||||
public CancellationToken ApplicationStopped => stopping.Token;
|
||||
public void StopApplication() => stopping.Cancel();
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
stopping.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0;net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<DefineConstants>$(DefineConstants);PLATFORM_TEST_VERIFIER_LIBRARY</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -19,8 +25,15 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<!-- The desktop SDK omits these from its default implicit usings. -->
|
||||
<Using Include="System.IO" />
|
||||
<Using Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MeetingAssistant\MeetingAssistant.csproj" />
|
||||
<ProjectReference Include="..\MeetingAssistant\MeetingAssistant.csproj" SetTargetFramework="TargetFramework=$(TargetFramework)" />
|
||||
<Compile Include="..\tools\VerifyPlatformTests.cs" Link="PlatformTestVerification.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
@@ -17,7 +16,7 @@ public sealed class MeetingWorkflowDiagnosticEndpointTests
|
||||
var firstRulesPath = Path.Combine(root, "first-rules.yaml");
|
||||
var secondRulesPath = Path.Combine(root, "second-rules.yaml");
|
||||
await File.WriteAllTextAsync(configPath, CreateConfigJson(firstRulesPath));
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
await using var factory = new IsolatedApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
// These are synthetic verifier fixtures, not execution evidence from a Windows host.
|
||||
public sealed class PlatformTestVerificationTests
|
||||
{
|
||||
private const string Guard = "MeetingAssistant.Tests.TestTargetCompositionTests.TestAndApplicationTargetsUseMatchingComposition";
|
||||
private const string UserEnvironment = "MeetingAssistant.Tests.AzureSpeechStreamingTranscriptionProviderTests.ResolveKeyFallsBackToUserEnvironment";
|
||||
private const string Glyphs = "MeetingAssistant.Tests.TaskbarIconTests.TaskbarIconGlyphsAreVisuallyCentered";
|
||||
private static readonly XNamespace Trx = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
private static readonly string[] OutlookCases =
|
||||
[
|
||||
"MeetingAssistant.Tests.OutlookClassicMeetingMetadataProviderTests.ExtractAgendaStopsBeforeTeamsJoinInformation",
|
||||
"MeetingAssistant.Tests.OutlookClassicMeetingMetadataProviderTests.NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail",
|
||||
"MeetingAssistant.Tests.OutlookClassicMeetingMetadataProviderTests.SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(subject: \"Canceled: Project sync\")",
|
||||
"MeetingAssistant.Tests.OutlookClassicMeetingMetadataProviderTests.SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(subject: \"Cancelled: Project sync\")",
|
||||
"MeetingAssistant.Tests.OutlookClassicMeetingMetadataProviderTests.SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(subject: \"Abgesagt: Project sync\")"
|
||||
];
|
||||
private static readonly string[] WindowsCropCases =
|
||||
[
|
||||
"MeetingAssistant.Tests.WindowsScreenshotImageCropperTests.WindowsCropUsesOcrPixelCoordinates",
|
||||
"MeetingAssistant.Tests.WindowsScreenshotImageCropperTests.WindowsCropPreservesExpectedPixelsAtNonzeroOrigin",
|
||||
"MeetingAssistant.Tests.WindowsScreenshotImageCropperTests.WindowsCropRejectsInvalidBounds"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void RejectsEmptyExecutionRecordsInsteadOfAcceptingSummaryCounts()
|
||||
{
|
||||
var trx = XDocument.Parse("""
|
||||
<TestRun xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Results />
|
||||
<ResultSummary outcome="Completed"><Counters total="602" passed="602" failed="0" notExecuted="0" /></ResultSummary>
|
||||
</TestRun>
|
||||
""");
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() =>
|
||||
PlatformTestVerification.Verify("portable", "MeetingAssistant.Tests.Example.Passes", trx));
|
||||
|
||||
Assert.Contains("execution records", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DerivesCurrentTotalsFromExecutionRecords()
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
|
||||
var result = PlatformTestVerification.Verify("portable", discovery, trx);
|
||||
|
||||
Assert.Equal(3, result.Total);
|
||||
Assert.Equal(1, result.Passed);
|
||||
Assert.Equal(2, result.Skipped);
|
||||
Assert.Contains("Totals: 3 discovered, 3 results, 1 passed, 2 skipped, 0 failed.", result.Lines);
|
||||
Assert.Contains("Windows crop cases: excluded from portable target (3 Windows-only native cases).", result.Lines);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Passed", "")]
|
||||
[InlineData("Passed", "platform-assurance: target=windows; host=linux")]
|
||||
[InlineData("Passed", "platform-assurance: target=portable; host=unknown")]
|
||||
[InlineData("NotExecuted", "platform-assurance: target=portable; host=linux")]
|
||||
[InlineData("Passed", "platform-assurance: target=portable; host=linux\nplatform-assurance: target=portable; host=windows")]
|
||||
public void RequiresMatchingPassedCompositionGuard(string outcome, string marker)
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
var guard = trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard);
|
||||
guard.SetAttributeValue("outcome", outcome);
|
||||
guard.Descendants(Trx + "StdOut").Single().Value = marker;
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
|
||||
Assert.Contains("composition guard", failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UserEnvironment, "linux", "Passed", "")]
|
||||
[InlineData(Glyphs, "macos", "Passed", "")]
|
||||
[InlineData(UserEnvironment, "linux", "NotExecuted", "")]
|
||||
[InlineData(Glyphs, "macos", "NotExecuted", "No explanation")]
|
||||
[InlineData(UserEnvironment, "windows", "NotExecuted", "Requires Windows per-user environment variables.")]
|
||||
[InlineData(Glyphs, "windows", "NotExecuted", "Requires Windows rendering.")]
|
||||
public void RequiresCompatibleExecutionOrReasonedWindowsSkip(string name, string host, string outcome, string reason)
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
var guard = trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard);
|
||||
guard.Descendants(Trx + "StdOut").Single().Value = $"platform-assurance: target=portable; host={host}";
|
||||
if (host == "windows")
|
||||
{
|
||||
foreach (var runtimeTest in trx.Descendants(Trx + "UnitTestResult").Where(record => (string?)record.Attribute("testName") != Guard))
|
||||
{
|
||||
runtimeTest.SetAttributeValue("outcome", "Passed");
|
||||
}
|
||||
}
|
||||
var record = trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == name);
|
||||
record.SetAttributeValue("outcome", outcome);
|
||||
record.Descendants(Trx + "StdOut").Single().Value = reason;
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
|
||||
Assert.Contains(name, failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsWindowsSkipNamesAndReasonsFromTrxErrorInfo()
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
foreach (var record in trx.Descendants(Trx + "UnitTestResult").Where(record => (string?)record.Attribute("outcome") == "NotExecuted"))
|
||||
{
|
||||
var output = record.Element(Trx + "Output")!;
|
||||
var reason = output.Element(Trx + "StdOut")!.Value;
|
||||
output.ReplaceNodes(new XElement(Trx + "ErrorInfo", new XElement(Trx + "Message", reason)));
|
||||
}
|
||||
|
||||
var result = PlatformTestVerification.Verify("portable", discovery, trx);
|
||||
|
||||
Assert.Contains($"Skipped: {UserEnvironment} — Requires Windows per-user environment variables; run on Windows.", result.Lines);
|
||||
Assert.Contains($"Skipped: {Glyphs} — Requires Windows System.Drawing bitmap rendering; run on Windows.", result.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsEvidenceNamesAllPassedOutlookAndNativeCropCases()
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
|
||||
var result = PlatformTestVerification.Verify("windows", discovery, trx);
|
||||
|
||||
Assert.Equal(11, result.Total);
|
||||
Assert.Equal(11, result.Passed);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
foreach (var name in OutlookCases)
|
||||
{
|
||||
Assert.Contains($"Passed: {name}", result.Lines);
|
||||
}
|
||||
foreach (var name in WindowsCropCases)
|
||||
{
|
||||
Assert.Contains($"Passed: {name}", result.Lines);
|
||||
}
|
||||
Assert.Contains("Test artifacts only; full Windows resource/PRI builds and native operational acceptance require separate evidence.", result.Lines);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "both")]
|
||||
[InlineData(1, "both")]
|
||||
[InlineData(2, "both")]
|
||||
[InlineData(0, "discovery")]
|
||||
[InlineData(1, "discovery")]
|
||||
[InlineData(2, "discovery")]
|
||||
[InlineData(0, "execution")]
|
||||
[InlineData(1, "execution")]
|
||||
[InlineData(2, "execution")]
|
||||
public void RequiresDiscoveryAndExecutionOfEachNativeWindowsCropCase(int missingCase, string missingArtifact)
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
var name = WindowsCropCases[missingCase];
|
||||
if (missingArtifact is "discovery" or "both")
|
||||
{
|
||||
discovery = discovery.Replace(name, "", StringComparison.Ordinal);
|
||||
}
|
||||
if (missingArtifact is "execution" or "both")
|
||||
{
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == name).Remove();
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("total", 10);
|
||||
counters.SetAttributeValue("executed", 10);
|
||||
counters.SetAttributeValue("passed", 10);
|
||||
}
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("windows", discovery, trx));
|
||||
|
||||
Assert.Contains(name, failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void ReasonedWindowsCropSkipsDoNotCountAsNativeExecutionEvidence(int skippedCase)
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
var name = WindowsCropCases[skippedCase];
|
||||
var record = trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == name);
|
||||
record.SetAttributeValue("outcome", "NotExecuted");
|
||||
record.Descendants(Trx + "StdOut").Single().Value = "Requires Windows System.Drawing bitmap rendering.";
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("executed", 10);
|
||||
counters.SetAttributeValue("passed", 10);
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("windows", discovery, trx));
|
||||
|
||||
Assert.Contains(name, failure.Message);
|
||||
Assert.Contains("Passed", failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void RequiresEachExistingOutlookCase(int missingCase)
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
var name = OutlookCases[missingCase];
|
||||
discovery = discovery.Replace(name, "", StringComparison.Ordinal);
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == name).Remove();
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("windows", discovery, trx));
|
||||
|
||||
Assert.Contains(name, failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("NotExecuted")]
|
||||
[InlineData("Failed")]
|
||||
public void RequiresExecutedPassedOutlookAssertions(string outcome)
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == OutlookCases[0])
|
||||
.SetAttributeValue("outcome", outcome);
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("windows", discovery, trx));
|
||||
|
||||
Assert.Contains(OutlookCases[0], failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableEvidenceRejectsOutlookCasesEvenWithWindowsRuntime()
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard)
|
||||
.Descendants(Trx + "StdOut").Single().Value = "platform-assurance: target=portable; host=windows";
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
|
||||
Assert.Contains("Outlook", failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("linux")]
|
||||
[InlineData("windows")]
|
||||
public void PortableEvidenceRejectsWindowsCropClassRegardlessOfRuntimeHost(string host)
|
||||
{
|
||||
const string cropCase = "MeetingAssistant.Tests.WindowsScreenshotImageCropperTests.AdditionalNativeCropCase";
|
||||
var (discovery, trx) = PortableFixture();
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard)
|
||||
.Descendants(Trx + "StdOut").Single().Value = $"platform-assurance: target=portable; host={host}";
|
||||
if (host == "windows")
|
||||
{
|
||||
foreach (var record in trx.Descendants(Trx + "UnitTestResult"))
|
||||
{
|
||||
record.SetAttributeValue("outcome", "Passed");
|
||||
}
|
||||
}
|
||||
discovery += '\n' + cropCase;
|
||||
trx.Root!.Element(Trx + "Results")!.Add(Result(cropCase, "Passed", ""));
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("total", 4);
|
||||
counters.SetAttributeValue("executed", host == "windows" ? 4 : 2);
|
||||
counters.SetAttributeValue("passed", host == "windows" ? 4 : 2);
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
|
||||
Assert.Contains("Windows crop", failure.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("duplicate discovery")]
|
||||
[InlineData("duplicate execution")]
|
||||
[InlineData("missing execution")]
|
||||
[InlineData("unlisted execution")]
|
||||
[InlineData("failed execution")]
|
||||
[InlineData("unrecognized outcome")]
|
||||
[InlineData("empty discovery")]
|
||||
public void RejectsIncompleteDuplicateOrFailedArtifactSets(string corruption)
|
||||
{
|
||||
const string example = "MeetingAssistant.Tests.Example.Passes";
|
||||
var (discovery, trx) = PortableFixture();
|
||||
var record = Result(example, "Passed", "");
|
||||
trx.Root!.Element(Trx + "Results")!.Add(record);
|
||||
discovery += '\n' + example;
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("total", 4);
|
||||
counters.SetAttributeValue("executed", 2);
|
||||
counters.SetAttributeValue("passed", 2);
|
||||
|
||||
switch (corruption)
|
||||
{
|
||||
case "duplicate discovery": discovery += '\n' + example; break;
|
||||
case "duplicate execution": record.AddAfterSelf(new XElement(record)); break;
|
||||
case "missing execution": record.Remove(); break;
|
||||
case "unlisted execution": discovery = discovery.Replace(example, "", StringComparison.Ordinal); break;
|
||||
case "failed execution": record.SetAttributeValue("outcome", "Failed"); break;
|
||||
case "unrecognized outcome": record.SetAttributeValue("outcome", "Aborted"); break;
|
||||
case "empty discovery": discovery = ""; break;
|
||||
}
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("total", "602")]
|
||||
[InlineData("executed", "602")]
|
||||
[InlineData("passed", "602")]
|
||||
[InlineData("failed", "1")]
|
||||
[InlineData("total", null)]
|
||||
[InlineData("total", "invalid")]
|
||||
[InlineData("aborted", "1")]
|
||||
public void RejectsInconsistentSummaryCounters(string counter, string? value)
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
trx.Descendants(Trx + "Counters").Single().SetAttributeValue(counter, value);
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("portable", discovery, trx));
|
||||
|
||||
Assert.Contains("summary", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsTargetRequiresWindowsRuntimeEvidence()
|
||||
{
|
||||
var (discovery, trx) = WindowsFixture();
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard)
|
||||
.Descendants(Trx + "StdOut").Single().Value = "platform-assurance: target=windows; host=linux";
|
||||
foreach (var record in trx.Descendants(Trx + "UnitTestResult")
|
||||
.Where(record => (string?)record.Attribute("testName") is UserEnvironment or Glyphs))
|
||||
{
|
||||
record.SetAttributeValue("outcome", "NotExecuted");
|
||||
record.Descendants(Trx + "StdOut").Single().Value = "Requires Windows runtime.";
|
||||
}
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("executed", 9);
|
||||
counters.SetAttributeValue("passed", 9);
|
||||
|
||||
var failure = Assert.Throws<InvalidDataException>(() => PlatformTestVerification.Verify("windows", discovery, trx));
|
||||
|
||||
Assert.Contains("Windows runtime", failure.Message);
|
||||
}
|
||||
|
||||
private static (string Discovery, XDocument Results) WindowsFixture()
|
||||
{
|
||||
var (discovery, trx) = PortableFixture();
|
||||
foreach (var record in trx.Descendants(Trx + "UnitTestResult"))
|
||||
{
|
||||
record.SetAttributeValue("outcome", "Passed");
|
||||
record.Descendants(Trx + "StdOut").Single().Value = "";
|
||||
}
|
||||
trx.Descendants(Trx + "UnitTestResult").Single(record => (string?)record.Attribute("testName") == Guard)
|
||||
.Descendants(Trx + "StdOut").Single().Value = "platform-assurance: target=windows; host=windows";
|
||||
trx.Root!.Element(Trx + "Results")!.Add(OutlookCases.Select(name => Result(name, "Passed", "")));
|
||||
trx.Root!.Element(Trx + "Results")!.Add(WindowsCropCases.Select(name => Result(name, "Passed", "")));
|
||||
var counters = trx.Descendants(Trx + "Counters").Single();
|
||||
counters.SetAttributeValue("total", 11);
|
||||
counters.SetAttributeValue("executed", 11);
|
||||
counters.SetAttributeValue("passed", 11);
|
||||
return (discovery + '\n' + string.Join('\n', OutlookCases) + '\n' + string.Join('\n', WindowsCropCases), trx);
|
||||
}
|
||||
|
||||
private static (string Discovery, XDocument Results) PortableFixture()
|
||||
{
|
||||
var results = new[]
|
||||
{
|
||||
Result(Guard, "Passed", "platform-assurance: target=portable; host=linux"),
|
||||
Result(UserEnvironment, "NotExecuted", "Requires Windows per-user environment variables; run on Windows."),
|
||||
Result(Glyphs, "NotExecuted", "Requires Windows System.Drawing bitmap rendering; run on Windows.")
|
||||
};
|
||||
return ("The following Tests are available:\n" + string.Join('\n', new[] { Guard, UserEnvironment, Glyphs }),
|
||||
new XDocument(new XElement(Trx + "TestRun",
|
||||
new XElement(Trx + "Results", results),
|
||||
new XElement(Trx + "ResultSummary", new XAttribute("outcome", "Completed"),
|
||||
new XElement(Trx + "Counters", new XAttribute("total", 3), new XAttribute("executed", 1),
|
||||
new XAttribute("passed", 1), new XAttribute("failed", 0), new XAttribute("notExecuted", 0))))));
|
||||
}
|
||||
|
||||
private static XElement Result(string name, string outcome, string output) =>
|
||||
new(Trx + "UnitTestResult", new XAttribute("testName", name), new XAttribute("outcome", outcome),
|
||||
new XElement(Trx + "Output", new XElement(Trx + "StdOut", output)));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -25,7 +24,7 @@ public sealed class SpeakerRecognitionFeatureSelectionTests
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
await using var factory = new IsolatedApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
|
||||
@@ -177,15 +177,10 @@ public sealed class TaskbarIconTests
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[WindowsFact("System.Drawing bitmap rendering")]
|
||||
[SupportedOSPlatform("windows")]
|
||||
public void TaskbarIconGlyphsAreVisuallyCentered()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var state in new[]
|
||||
{
|
||||
RecordingProcessState.Idle,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Versioning;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class TestTargetCompositionTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestAndApplicationTargetsUseMatchingComposition()
|
||||
{
|
||||
var testAssembly = typeof(TestTargetCompositionTests).Assembly;
|
||||
var applicationAssembly = typeof(Program).Assembly;
|
||||
var testFramework = testAssembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName;
|
||||
var applicationFramework = applicationAssembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName;
|
||||
var testPlatform = testAssembly.GetCustomAttribute<TargetPlatformAttribute>()?.PlatformName;
|
||||
var applicationPlatform = applicationAssembly.GetCustomAttribute<TargetPlatformAttribute>()?.PlatformName;
|
||||
|
||||
output.WriteLine($"Test framework: {testFramework}; platform: {testPlatform ?? "portable"}");
|
||||
output.WriteLine($"Application framework: {applicationFramework}; platform: {applicationPlatform ?? "portable"}");
|
||||
Assert.Equal(".NETCoreApp,Version=v10.0", testFramework);
|
||||
Assert.Equal(testFramework, applicationFramework);
|
||||
Assert.Equal(testPlatform, applicationPlatform);
|
||||
#if WINDOWS
|
||||
Assert.Equal("Windows10.0.19041.0", testPlatform, ignoreCase: true);
|
||||
#else
|
||||
Assert.Null(testPlatform);
|
||||
#endif
|
||||
|
||||
await using var factory = new IsolatedApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var health = await client.GetAsync("/health");
|
||||
var capabilities = await client.GetFromJsonAsync<PlatformCapabilityReport>("/diagnostics/platform-capabilities");
|
||||
using var requests = await client.GetAsync("/desktop/requests");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
|
||||
Assert.NotNull(capabilities);
|
||||
#if WINDOWS
|
||||
Assert.Equal("OutlookClassicMeetingMetadataProvider", capabilities.MeetingMetadataProvider);
|
||||
Assert.Equal("WpfWorkflowRulesEditorWindowService", capabilities.WorkflowEditorProvider);
|
||||
Assert.IsType<WindowsAgentApprovalPromptService>(factory.Services.GetRequiredService<IAgentApprovalPromptService>());
|
||||
Assert.IsType<WindowsMeetingInactivityPromptService>(factory.Services.GetRequiredService<IMeetingInactivityPromptService>());
|
||||
Assert.Equal(HttpStatusCode.NotFound, requests.StatusCode);
|
||||
const string target = "windows";
|
||||
#else
|
||||
Assert.Equal("MacOsWorkflowRulesEditorWindowService", capabilities.WorkflowEditorProvider);
|
||||
Assert.Equal(HttpStatusCode.OK, requests.StatusCode);
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Assert.Equal("MacOsMeetingMetadataProvider", capabilities.MeetingMetadataProvider);
|
||||
Assert.IsType<MacOsAgentApprovalPromptService>(factory.Services.GetRequiredService<IAgentApprovalPromptService>());
|
||||
Assert.IsType<MacOsMeetingInactivityPromptService>(factory.Services.GetRequiredService<IMeetingInactivityPromptService>());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal("NoopMeetingMetadataProvider", capabilities.MeetingMetadataProvider);
|
||||
Assert.IsType<NoopAgentApprovalPromptService>(factory.Services.GetRequiredService<IAgentApprovalPromptService>());
|
||||
Assert.IsType<NoopMeetingInactivityPromptService>(factory.Services.GetRequiredService<IMeetingInactivityPromptService>());
|
||||
}
|
||||
const string target = "portable";
|
||||
#endif
|
||||
var host = OperatingSystem.IsWindows() ? "windows" :
|
||||
OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "other";
|
||||
output.WriteLine($"platform-assurance: target={target}; host={host}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class WindowsFactAttribute : FactAttribute
|
||||
{
|
||||
public WindowsFactAttribute(string requiredCapability)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Skip = $"Requires Windows {requiredCapability}; run on Windows.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#if WINDOWS
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class WindowsScreenshotImageCropperTests(ITestOutputHelper output)
|
||||
{
|
||||
[WindowsFact("System.Drawing screenshot cropping")]
|
||||
public Task WindowsCropUsesOcrPixelCoordinates() =>
|
||||
AssertCropAsync(new ScreenshotCropCoordinates(0, 0, 2, 2));
|
||||
|
||||
[WindowsFact("System.Drawing screenshot cropping")]
|
||||
public Task WindowsCropPreservesExpectedPixelsAtNonzeroOrigin() =>
|
||||
AssertCropAsync(new ScreenshotCropCoordinates(1, 1, 3, 2));
|
||||
|
||||
[WindowsFact("System.Drawing screenshot cropping")]
|
||||
public async Task WindowsCropRejectsInvalidBounds()
|
||||
{
|
||||
using var fixture = new ImageFixture();
|
||||
await using var factory = new IsolatedApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var cropper = GetWindowsCropper(factory);
|
||||
ScreenshotCropCoordinates[] invalidBounds =
|
||||
[
|
||||
new(-1, 0, 1, 1), new(0, -1, 1, 1),
|
||||
new(0, 0, 0, 1), new(0, 0, 1, 0),
|
||||
new(0, 0, -1, 1), new(0, 0, 1, -1),
|
||||
new(4, 0, 1, 1), new(0, 3, 1, 1),
|
||||
new(3, 0, 2, 1), new(0, 2, 1, 2)
|
||||
];
|
||||
|
||||
foreach (var bounds in invalidBounds)
|
||||
{
|
||||
var failure = await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
cropper.SaveCroppedScreenshotAsync(fixture.InputPath, bounds, CancellationToken.None));
|
||||
Assert.Contains("outside image bounds 4x3", failure.Message);
|
||||
Assert.False(File.Exists(fixture.CroppedPath));
|
||||
output.WriteLine($"Rejected {bounds.X},{bounds.Y},{bounds.Width},{bounds.Height}; no output created.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertCropAsync(ScreenshotCropCoordinates bounds)
|
||||
{
|
||||
using var fixture = new ImageFixture();
|
||||
await using var factory = new IsolatedApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var cropper = GetWindowsCropper(factory);
|
||||
|
||||
var path = await cropper.SaveCroppedScreenshotAsync(
|
||||
fixture.InputPath, bounds, CancellationToken.None);
|
||||
|
||||
Assert.Equal(fixture.CroppedPath, path);
|
||||
using var cropped = new Bitmap(path!);
|
||||
Assert.Equal(bounds.Width, cropped.Width);
|
||||
Assert.Equal(bounds.Height, cropped.Height);
|
||||
for (var y = 0; y < cropped.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < cropped.Width; x++)
|
||||
{
|
||||
Assert.Equal(ImageFixture.PixelAt(bounds.X + x, bounds.Y + y).ToArgb(),
|
||||
cropped.GetPixel(x, y).ToArgb());
|
||||
}
|
||||
}
|
||||
output.WriteLine($"Verified every pixel of {bounds.Width}x{bounds.Height} at original origin {bounds.X},{bounds.Y}.");
|
||||
}
|
||||
|
||||
private IScreenshotImageCropper GetWindowsCropper(IsolatedApplicationFactory factory)
|
||||
{
|
||||
var cropper = factory.Services.GetRequiredService<IScreenshotImageCropper>();
|
||||
Assert.IsType<WindowsScreenshotImageCropper>(cropper);
|
||||
output.WriteLine($"Registered cropper: {cropper.GetType().Name}; host: {RuntimeInformation.OSDescription}; " +
|
||||
$"architecture: {RuntimeInformation.ProcessArchitecture}; runtime: {RuntimeInformation.FrameworkDescription}; " +
|
||||
"target: net10.0-windows10.0.19041.0.");
|
||||
return cropper;
|
||||
}
|
||||
|
||||
private sealed class ImageFixture : IDisposable
|
||||
{
|
||||
private readonly string root = Path.Combine(Path.GetTempPath(), $"meeting-assistant-windows-crop-{Guid.NewGuid():N}");
|
||||
|
||||
public ImageFixture()
|
||||
{
|
||||
Directory.CreateDirectory(root);
|
||||
using var image = new Bitmap(4, 3, PixelFormat.Format32bppArgb);
|
||||
for (var y = 0; y < image.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < image.Width; x++)
|
||||
{
|
||||
image.SetPixel(x, y, PixelAt(x, y));
|
||||
}
|
||||
}
|
||||
image.Save(InputPath, ImageFormat.Png);
|
||||
}
|
||||
|
||||
public string InputPath => Path.Combine(root, "input.png");
|
||||
public string CroppedPath => Path.Combine(root, "input-cropped.png");
|
||||
|
||||
// Each original coordinate has a distinct opaque color; no resampling tolerance hides a shifted origin.
|
||||
public static Color PixelAt(int x, int y) => Color.FromArgb(255, 30 + x * 50, 20 + y * 70, 10 + x * 15 + y * 5);
|
||||
|
||||
public void Dispose() => Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in new issue
Block a user