using MeetingAssistant.Recording; 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.Logging.Abstractions; using System.Net.Http.Json; namespace MeetingAssistant.Tests; public sealed class MacOsMeetingAudioSourceTests { [Fact] public void MacOsServiceLauncherDefinesStablePrivacyIdentity() { if (!OperatingSystem.IsMacOS()) { return; } var projectDirectory = Path.GetFullPath(Path.Combine( AppContext.BaseDirectory, "..", "..", "..", "..", "MeetingAssistant")); var launcherDirectory = Path.Combine(projectDirectory, "Native", "MacOsLauncher"); var infoPlistPath = Path.Combine(launcherDirectory, "Info.plist"); var launcherSourcePath = Path.Combine(launcherDirectory, "main.swift"); Assert.True(File.Exists(infoPlistPath), $"Expected launcher Info.plist at '{infoPlistPath}'."); Assert.True(File.Exists(launcherSourcePath), $"Expected launcher source at '{launcherSourcePath}'."); var infoPlist = File.ReadAllText(infoPlistPath); Assert.Contains("cloud.schweigert.meeting-assistant", infoPlist, StringComparison.Ordinal); Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal); Assert.Contains("NSCalendarsFullAccessUsageDescription", infoPlist, StringComparison.Ordinal); var launcherSource = File.ReadAllText(launcherSourcePath); Assert.Contains("set -a", launcherSource, StringComparison.Ordinal); Assert.Contains("Contents/Resources/app", launcherSource, StringComparison.Ordinal); var projectFile = File.ReadAllText(Path.Combine(projectDirectory, "MeetingAssistant.csproj")); Assert.Contains( "designated => identifier "cloud.schweigert.meeting-assistant"", projectFile, StringComparison.Ordinal); } [Fact] public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant() { if (!OperatingSystem.IsMacOS()) { return; } var configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent?.Name ?? "Debug"; var appPath = Path.GetFullPath(Path.Combine( AppContext.BaseDirectory, "..", "..", "..", "..", "MeetingAssistant", "bin", configuration, "net10.0", "Native", "MeetingAssistantAudioCapture.app")); var infoPlistPath = Path.Combine(appPath, "Contents", "Info.plist"); var helperPath = Path.Combine(appPath, "Contents", "MacOS", "macos-meeting-audio-capture"); Assert.True(Directory.Exists(appPath), $"Expected macOS audio capture app at '{appPath}'."); Assert.True(File.Exists(infoPlistPath), $"Expected Info.plist at '{infoPlistPath}'."); Assert.True(File.Exists(helperPath), $"Expected native helper at '{helperPath}'."); var infoPlist = File.ReadAllText(infoPlistPath); Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", infoPlist, StringComparison.Ordinal); Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal); using var verification = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "/usr/bin/codesign", ArgumentList = { "--verify", "--deep", "--strict", appPath }, RedirectStandardError = true, UseShellExecute = false }); Assert.NotNull(verification); verification.WaitForExit(); Assert.True( verification.ExitCode == 0, $"Expected a valid app-bundle signature: {verification.StandardError.ReadToEnd()}"); } [Fact] public async Task MacOsMicrophoneStreamsNativePcmUsingRunFormat() { var pcm = Pcm16(1_000, -2_000, 3_000, -4_000); var process = new StubMacOsAudioCaptureProcess(pcm, exitCode: 0); var processFactory = new StubMacOsAudioCaptureProcessFactory(process); IMeetingAudioSource source = new MacOsMicrophoneAudioSource( processFactory, NullLogger.Instance); var options = new MeetingAssistantOptions { Recording = { SampleRate = 24_000, Channels = 2 } }; var chunks = await ReadChunksAsync(source, options); var chunk = Assert.Single(chunks); Assert.Equal(pcm, chunk.Pcm); Assert.Equal(24_000, chunk.SampleRate); Assert.Equal(2, chunk.Channels); Assert.Equal( new MacOsAudioCaptureRequest(MacOsAudioCaptureKind.Microphone, 24_000, 2), processFactory.Request); Assert.True(process.Disposed); } [Fact] public async Task MacOsSystemAudioReportsNativePermissionFailure() { var process = new StubMacOsAudioCaptureProcess( [], exitCode: 77, standardError: "Screen Recording/System Audio permission is required."); IMeetingAudioSource source = new MacOsSystemAudioSource( new StubMacOsAudioCaptureProcessFactory(process), NullLogger.Instance); var exception = await Assert.ThrowsAsync(async () => await ReadChunksAsync(source, new MeetingAssistantOptions())); Assert.Contains("Screen Recording/System Audio permission", exception.Message, StringComparison.Ordinal); } [Fact] public void PortableRegistrationUsesMacOsCompositeOnlyForMacOs() { var macServices = new ServiceCollection().AddLogging(); var unsupportedServices = new ServiceCollection().AddLogging(); PortableMeetingAudioRegistration.Add(macServices, isMacOs: true); PortableMeetingAudioRegistration.Add(unsupportedServices, isMacOs: false); using var macProvider = macServices.BuildServiceProvider(); using var unsupportedProvider = unsupportedServices.BuildServiceProvider(); Assert.IsType(macProvider.GetRequiredService()); Assert.IsType(unsupportedProvider.GetRequiredService()); } [Fact] public async Task RecordingEndpointsCaptureMixedMacOsAudioAndStopNativeProcesses() { var testRoot = Path.Combine(Path.GetTempPath(), $"meeting-assistant-macos-endpoint-{Guid.NewGuid():N}"); var nativeProcesses = new EndpointMacOsAudioCaptureProcessFactory( microphonePcm: Pcm16(2_000), systemPcm: Pcm16(10_000)); var speechPipelines = new CapturingSpeechRecognitionPipelineFactory(); var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["MeetingAssistant:Vault:BaseFolder"] = testRoot, ["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts", ["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes", ["MeetingAssistant:Vault:AssistantContextFolder"] = "Context", ["MeetingAssistant:Vault:SummariesFolder"] = "Summaries", ["MeetingAssistant:Vault:ProjectsFolder"] = "Projects", ["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(testRoot, "Recordings"), ["MeetingAssistant:Recording:InactivitySafeguard:Enabled"] = "false", ["MeetingAssistant:SpeakerIdentification:DatabasePath"] = Path.Combine(testRoot, "speakers.db"), ["MeetingAssistant:FunAsr:Backend:Enabled"] = "false" }); }); builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); PortableMeetingAudioRegistration.Add(services, isMacOs: true); services.RemoveAll(); services.AddSingleton(nativeProcesses); services.RemoveAll(); services.AddSingleton(speechPipelines); services.RemoveAll(); services.AddSingleton(); services.RemoveAll(); services.AddSingleton(); }); }); try { using var client = factory.CreateClient(); using var startResponse = await client.PostAsync("/recording/start", content: null); var started = await startResponse.Content.ReadFromJsonAsync(); var mixedChunk = await speechPipelines.FirstChunk.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(startResponse.IsSuccessStatusCode); Assert.True(started?.IsRecording); Assert.Equal(12_000, BitConverter.ToInt16(mixedChunk.Pcm)); using var stopResponse = await client.PostAsync("/recording/stop", content: null); var stopped = await stopResponse.Content.ReadFromJsonAsync(); Assert.True(stopResponse.IsSuccessStatusCode); Assert.False(stopped?.IsRecording); Assert.All(nativeProcesses.Processes, process => Assert.True(process.Terminated)); } finally { await factory.DisposeAsync(); using var connection = new SqliteConnection($"Data Source={Path.Combine(testRoot, "speakers.db")}"); SqliteConnection.ClearPool(connection); if (Directory.Exists(testRoot)) { Directory.Delete(testRoot, recursive: true); } } } private static async Task> ReadChunksAsync( IMeetingAudioSource source, MeetingAssistantOptions options) { var chunks = new List(); await foreach (var chunk in source.CaptureAsync(options, CancellationToken.None)) { chunks.Add(chunk); } return chunks; } private static byte[] Pcm16(params short[] samples) { var pcm = new byte[samples.Length * sizeof(short)]; Buffer.BlockCopy(samples, 0, pcm, 0, pcm.Length); return pcm; } private sealed class StubMacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory { private readonly IMacOsAudioCaptureProcess process; public StubMacOsAudioCaptureProcessFactory(IMacOsAudioCaptureProcess process) { this.process = process; } public MacOsAudioCaptureRequest? Request { get; private set; } public IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request) { Request = request; return process; } } private sealed class StubMacOsAudioCaptureProcess : IMacOsAudioCaptureProcess { private readonly MemoryStream standardOutput; private readonly string standardError; public StubMacOsAudioCaptureProcess(byte[] pcm, int exitCode, string standardError = "") { standardOutput = new MemoryStream(pcm); this.standardError = standardError; ExitCode = exitCode; } public Stream StandardOutput => standardOutput; public int ExitCode { get; } public bool HasExited => true; public bool Disposed { get; private set; } public Task ReadStandardErrorAsync(CancellationToken cancellationToken) { return Task.FromResult(standardError); } public Task WaitForExitAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } public void Terminate() { } public ValueTask DisposeAsync() { Disposed = true; standardOutput.Dispose(); return ValueTask.CompletedTask; } } private sealed class EndpointMacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory { private readonly IReadOnlyDictionary pcmByKind; public EndpointMacOsAudioCaptureProcessFactory(byte[] microphonePcm, byte[] systemPcm) { pcmByKind = new Dictionary { [MacOsAudioCaptureKind.Microphone] = microphonePcm, [MacOsAudioCaptureKind.System] = systemPcm }; } public List Processes { get; } = []; public IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request) { var process = new BlockingMacOsAudioCaptureProcess(pcmByKind[request.Kind]); Processes.Add(process); return process; } } private sealed class BlockingMacOsAudioCaptureProcess : IMacOsAudioCaptureProcess { private readonly TaskCompletionSource terminated = new(TaskCreationOptions.RunContinuationsAsynchronously); public BlockingMacOsAudioCaptureProcess(byte[] pcm) { StandardOutput = new InitialPcmThenBlockingStream(pcm, terminated.Task); } public Stream StandardOutput { get; } public int ExitCode => Terminated ? 137 : throw new InvalidOperationException("Capture is still running."); public bool HasExited => Terminated; public bool Terminated => terminated.Task.IsCompleted; public Task ReadStandardErrorAsync(CancellationToken cancellationToken) { return Task.FromResult(""); } public Task WaitForExitAsync(CancellationToken cancellationToken) { return terminated.Task.WaitAsync(cancellationToken); } public void Terminate() { terminated.TrySetResult(); } public ValueTask DisposeAsync() { StandardOutput.Dispose(); return ValueTask.CompletedTask; } } private sealed class InitialPcmThenBlockingStream : Stream { private readonly byte[] pcm; private readonly Task processExit; private bool read; public InitialPcmThenBlockingStream(byte[] pcm, Task processExit) { this.pcm = pcm; this.processExit = processExit; } public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { if (!read) { read = true; pcm.AsSpan().CopyTo(buffer.Span); return pcm.Length; } await processExit.WaitAsync(cancellationToken); return 0; } public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override void Flush() { } } private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { public TaskCompletionSource FirstChunk { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public ISpeechRecognitionPipeline Create() { return new CapturingSpeechRecognitionPipeline(FirstChunk); } } private sealed class CapturingSpeechRecognitionPipeline : ISpeechRecognitionPipeline { private readonly TaskCompletionSource firstChunk; private readonly TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); public CapturingSpeechRecognitionPipeline(TaskCompletionSource firstChunk) { this.firstChunk = firstChunk; } public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task InitializeAsync( SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitUntilReadyAsync(CancellationToken cancellationToken) => Task.CompletedTask; public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken) { firstChunk.TrySetResult(chunk); return ValueTask.CompletedTask; } public Task CompleteAsync(CancellationToken cancellationToken) { completed.TrySetResult(); return Task.CompletedTask; } public async IAsyncEnumerable ReadLiveTranscriptAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await completed.Task.WaitAsync(cancellationToken); yield break; } public Task> ReadFinishedTranscriptAsync( string audioPath, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { return Task.FromResult>([]); } public ValueTask DisposeAsync() { completed.TrySetResult(); return ValueTask.CompletedTask; } } private sealed class NoopMeetingNoteOpener : IMeetingNoteOpener { public Task OpenAsync(string notePath, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class NoopMeetingSummaryPipeline : IMeetingSummaryPipeline { public Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "", Succeeded: true)); } } }