using System.Diagnostics; using System.Text.RegularExpressions; namespace VoiceCat.Tests; public class PublishServerScriptTests { [Theory] [InlineData("clients/windows/publish-client.ps1")] [InlineData("scripts/publish-server.ps1")] public async Task PublishUsesRegeneratedIntermediateRuntimeLock(string relativePath) { string script = await File.ReadAllTextAsync(Path.Combine(FindRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar))); Assert.Contains("NuGetLockFilePath=obj/packages.publish.", script); Assert.Contains("RestoreLockedMode=false", script); Assert.DoesNotContain("NuGetLockFilePath=packages.publish.", script); } [Fact] public async Task LicenseAuditIgnoresGeneratedBuildLocks() { string script = await File.ReadAllTextAsync(Path.Combine(FindRoot(), "scripts", "check-licenses.ps1")); Assert.Contains("(?:bin|obj)", script); } [Fact] public async Task MacPublishPreservesAppEntitlementsAndReplacesBrokenWorkloadBundle() { string root = FindRoot(); string script = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "publish-macos.sh")); string entitlements = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.Mac", "VoiceCat.Mac.entitlements")); Assert.Contains("sign_nested_code", script); Assert.Contains("--entitlements \"$entitlements\"", script); Assert.Contains("install_verified_primary_bundle", script); Assert.DoesNotContain("codesign --force --deep", script); Assert.Contains("com.apple.security.cs.allow-jit", entitlements); } [Fact] public async Task IosDeviceBuildUsesAnIntermediateDeviceLockAndScopesKnownTrimWarnings() { string root = FindRoot(); string script = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "build-ios-device.sh")); string project = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "VoiceCat.iOS.csproj")); Assert.Contains("NuGetLockFilePath=\"$device_lock\"", script); Assert.Contains("RestoreLockedMode=false", script); Assert.Contains("$(WarningsNotAsErrors);IL2104", project); Assert.DoesNotContain("false", project); } [Fact] public async Task IosBroadcastExtensionPreservesSharedAppGroupAndSupportsDistributionSigning() { string root = FindRoot(); string entitlements = await File.ReadAllTextAsync(Path.Combine( root, "native", "apple", "broadcast", "VoiceCatBroadcast.entitlements")); string script = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "build-broadcast-extension.sh")); Assert.Contains("com.apple.security.application-groups", entitlements); Assert.Contains("group.me.iamtalon.voicecat", entitlements); Assert.Contains("CODE_SIGN_STYLE=Manual", script); Assert.Contains("PROVISIONING_PROFILE_SPECIFIER=\"$VOICECAT_BROADCAST_CODESIGN_PROVISION\"", script); // Both version placeholders must always be passed to xcodebuild. A conditional // CURRENT_PROJECT_VERSION expands to an empty CFBundleVersion, which installd rejects. Assert.Contains("CURRENT_PROJECT_VERSION=\"${VOICECAT_BUILD_NUMBER:-1}\"", script); Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script); Assert.DoesNotContain("if [[ -n \"${VOICECAT_BUILD_NUMBER:-}\" ]]; then", script); string deviceScript = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "build-ios-device.sh")); Assert.Contains("-p:ApplicationVersion=\"$VOICECAT_BUILD_NUMBER\"", deviceScript); string appManifest = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "Info.plist")); string extensionManifest = await File.ReadAllTextAsync(Path.Combine( root, "native", "apple", "broadcast", "Info.plist")); Assert.DoesNotContain("CFBundleVersion", appManifest); Assert.Contains("$(CURRENT_PROJECT_VERSION)", extensionManifest); } [Fact] public async Task IosHostDeclaresItsAppIconAssetForAppStoreValidation() { string root = FindRoot(); string manifest = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "Info.plist")); string iconSet = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "Assets.xcassets", "AppIcon.appiconset", "Contents.json")); // XSAppIconAssets is an app-manifest key, not an MSBuild property. Without it actool // never receives --app-icon, so the bundle ships without CFBundleIconName and App Store // Connect rejects the upload with errors 90022, 90023, and 90713. Assert.Contains("XSAppIconAssets", manifest); Assert.Contains("Assets.xcassets/AppIcon.appiconset", manifest); Assert.Contains("AppIcon-1024.png", iconSet); } [Fact] public async Task IosScreenSharingDeclaresBackgroundCaptureAndKeepsReplayKitFallback() { string root = FindRoot(); string manifest = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "Info.plist")); string settings = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "SettingsController.cs")); Assert.Contains("screen-capture", manifest); Assert.Contains("if (IosScreenCapture.IsAvailable)", settings); Assert.Contains("new RPSystemBroadcastPickerView", settings); Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings); } [Fact] public async Task IosBroadcastPumpKeepsOneRingMappingAcrossTicks() { string root = FindRoot(); string pump = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs")); // A mapping pair plus its container lookup and path strings per 5 ms tick allocated // steadily at 200 Hz and churned the GC under long calls. The pump opens one mapping on // the ring's transitions and reuses it; the producer never replaces the ring file. Assert.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile")); Assert.Contains("private bool OpenMapping()", pump); Assert.Contains("private void CloseMapping()", pump); Assert.Contains("Thread.Sleep(view is null ? 100 : 5)", pump); Assert.Contains("File.Exists(path)", pump); Assert.DoesNotContain("using MemoryMappedFile", pump); Assert.DoesNotContain("using MemoryMappedViewAccessor", pump); } [Fact] public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers() { string root = FindRoot(); string microphone = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs")); string screen = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs")); Assert.DoesNotContain("PeriodicTimer", microphone); Assert.Contains("owner.Audio.FeedPcm(route.StreamId", microphone); Assert.Contains("new Thread(Run)", screen); Assert.DoesNotContain("Task.Delay(10", screen); Assert.Contains("MaximumCaptureCallbackFrames = 16_384", microphone); Assert.DoesNotContain("Math.Ceiling(4_096 * 48_000", microphone); // Both 20 ms hands-offs run from the AVAudioSourceNode render callback: sleep-paced // threads coalesce when the app is backgrounded and glitch the call after several minutes. string model = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "AppModel.cs")); Assert.DoesNotContain("new Thread(", microphone); Assert.DoesNotContain("Thread.Sleep", microphone); Assert.Contains("owner.Audio.RunCycle()", microphone); Assert.Contains("while (microphoneCredit >= 960)", microphone); Assert.Contains("deviceClockedAudio: true", model); } [Fact] public async Task IosRecoversBackgroundAudioWithoutForegrounding() { string root = FindRoot(); string microphone = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs")); string router = await File.ReadAllTextAsync(Path.Combine( root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs")); // The render callback is the only clock for the device-clocked pipeline, so a graph that // stops while backgrounded froze capture, mix and send until the app was foregrounded. Assert.Contains("internal long RenderCallbacks", microphone); Assert.Contains("internal bool IsRunning", microphone); Assert.Contains("private void TickWatchdog()", router); Assert.Contains("IosAudioEngine.Shared.RenderCallbacks", router); Assert.Contains("IosAudioEngine.Shared.IsRunning", router); // Bounded: a rebuild that does not restore the callbacks backs off and then stops, so a // graph that cannot start fails visibly instead of churning the session forever. Assert.Contains("IosAudioEngine.Shared.Reconfigure(true, $\"stall watchdog {watchdogAttempts}\")", router); Assert.Contains("watchdogAttempts >= MaximumWatchdogAttempts", router); Assert.Contains("VC_WATCHDOG exhausted", router); // SetActive fails for as long as an interruption is in force, so Began suppresses // recovery and the watchdog retries the rebuild that Ended asks for. Assert.Contains("AVAudioSessionInterruptionType.Began", router); Assert.Contains("interrupted = true", router); Assert.Contains("internal void ResumeForeground()", router); // A stalled render callback resynchronizes its own rings, and a refill is bounded so it // cannot overrun the callback deadline that it is recovering from. Assert.Contains("owner.Audio.ResynchronizeInputs()", microphone); Assert.Contains("playbackRing.Resynchronize()", microphone); Assert.Contains("Ring.Resynchronize()", microphone); Assert.Contains("Math.Min(frames + playbackRing.TargetFrames - playbackRing.CountFrames, frames + 960)", microphone); } [Fact] public async Task IosAudioCommitsInputRouteBeforeChannelsAndVoiceProcessingBeforeConnections() { string root = FindRoot(); string router = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs")); string engine = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs")); Assert.True(router.IndexOf("SetActive(true", StringComparison.Ordinal) < router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal)); Assert.True(router.IndexOf("session.SetPreferredInput(port", StringComparison.Ordinal) < router.IndexOf("port.SetPreferredDataSource(source", StringComparison.Ordinal)); Assert.DoesNotContain("session.SetInputDataSource(source", router); Assert.DoesNotContain("session.SetPreferredInputNumberOfChannels", router); Assert.DoesNotContain("MaximumInputNumberOfChannels", router); Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router); Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router); Assert.Contains("AVAudioSession.PolarPatternStereo", router); Assert.Contains("SupportsStereoPolarPattern", router); Assert.Contains("AdaptivePcmBuffer Ring = new(channels, 120, 65_536)", engine); Assert.Contains("SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown", router); Assert.Contains("ClearStereoPolarPattern(session)", router); Assert.Contains("CaptureChannels == 2\n ? port.DataSources?.FirstOrDefault", router); Assert.True(engine.IndexOf("SetVoiceProcessingEnabled", StringComparison.Ordinal) < engine.IndexOf("next.Connect(source", StringComparison.Ordinal)); Assert.Contains("AVAudioEngine.ConfigurationChangeNotification", engine); Assert.Contains("if (!IsConnected || !ReferenceEquals(engine, next)) return;", engine); Assert.Contains("if (!next.Running || HardwareChanged()) Rebuild(\"configuration change\");", engine); // With voice processing the input and output are one IO unit, and it only stays up while the // input is rendered. A tap alone leaves it out of the chain and the unit dies seconds later. Assert.Contains("next.Connect(input, captureSink, inputFormat", engine); Assert.Contains("captureSink.OutputVolume = 0f;", engine); // Capture exists for the session, so joining voice names a stream instead of rebuilding. Assert.Contains("internal void StopMicrophone() { Volatile.Write(ref microphone, null); }", engine); Assert.Contains("bool captures = IsConnected;", engine); } [Fact] public async Task IosSpeakerOutputIsAnOrthogonalOneShotChoice() { string router = await File.ReadAllTextAsync(Path.Combine( FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs")); // Speaker output decides where audio goes, not how it is captured, so it must not take the // preset with it, and no preset may clear it back. Assert.Contains("ForceSpeaker = value; speakerIsExplicit = overridePending = true", router); Assert.DoesNotContain("ForceSpeaker = value; Preset = IosAudioPreset.Advanced", router); Assert.DoesNotContain("IosAudioPreset.VoiceChat) ForceSpeaker = false", router); // The stored flag is honoured only once the user has actually chosen, so the value older // installs inherited from the voice-chat preset cannot pin a headset user to the speaker. Assert.Contains("cat.voice.audio.speakerIsExplicit", router); // The port override is the toggle's one-shot request. Re-asserting it on every rebuild // means fighting the system for the route, which the user sees as audio flipping. Assert.Contains("if (overridePending)", router); Assert.Contains("overridePending = false;", router); // Every reconfiguration moves the route and moving the route notifies the handler, so a // route change must never force a rebuild: that is one rebuild per notification, without // end. The graph is rebuilt only when it stopped or when the hardware it was built around // moved, which is the one thing AVAudioEngine cannot absorb by itself. Assert.Contains("if (applying) return;", router); Assert.Contains("Recover($\"route change ({reason})\", force: false)", router); Assert.DoesNotContain("Recover($\"route change ({reason})\");", router); string engine = await File.ReadAllTextAsync(Path.Combine( FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs")); // Asked of the graph's own input, not of AVAudioSession and not remembered from a route: // the session's reported rate and channel count do not settle until after the graph has // started, and CurrentRoute still names the previous route for a while, so either one // reports a change that has not happened and every rebuild reports it again. Assert.Contains("live.InputNode.GetBusOutputFormat(0)", engine); Assert.Contains("format.SampleRate != builtInputRate || format.ChannelCount != builtInputChannels", engine); Assert.DoesNotContain("AVAudioSession.SharedInstance().SampleRate", engine); // Every rebuild names what asked for it, so a storm can be read from a log. Assert.Contains("VC_REBUILD cause=", engine); // Building a graph is itself what posts most configuration changes, and the engine reads as // stopped until voice processing has rebuilt the IO. Nothing may judge a graph dead inside // that window, or it replaces the graph it just built with one that reports the same thing. Assert.Contains("internal bool Settling =>", engine); Assert.Contains("if (!force && Settling) return;", engine); Assert.Contains("if (Settling) return;", engine); Assert.Contains("if (Settling) return true;", engine); Assert.Contains("|| IosAudioEngine.Shared.Settling) { ResetWatchdog(); return; }", router); Assert.Contains("&& !HardwareChanged()) return;", engine); Assert.Contains("(!next.Running || HardwareChanged())", engine); Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }", router); Assert.DoesNotContain("SelectedInputId ??= session.PreferredInput", router); } [Fact] public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne() { string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1"); string allTargets = await RunWhatIf(script); Assert.Contains("win-x64", allTargets); Assert.Contains("linux-x64", allTargets); string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64"); Assert.Contains("linux-x64", linuxOnly); Assert.DoesNotContain("win-x64", linuxOnly); } private static async Task RunWhatIf(string script, params string[] arguments) { string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh"; var start = new ProcessStartInfo(powerShell) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments)) start.ArgumentList.Add(argument); using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell."); Task stdout = process.StandardOutput.ReadToEndAsync(); Task stderr = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); string output = await stdout + await stderr; Assert.True(process.ExitCode == 0, output); return output; } private static string FindRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx"))) directory = directory.Parent; return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); } }