2026-09-20 01:15:52 +02:00
|
|
|
using System.Diagnostics;
|
2026-09-23 21:47:42 +02:00
|
|
|
using System.Text.RegularExpressions;
|
2026-09-20 01:15:52 +02:00
|
|
|
|
|
|
|
|
namespace VoiceCat.Tests;
|
|
|
|
|
|
|
|
|
|
public class PublishServerScriptTests
|
|
|
|
|
{
|
2026-09-21 02:12:03 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 02:53:27 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 03:10:25 +02:00
|
|
|
[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>$(WarningsNotAsErrors);IL2104</WarningsNotAsErrors>", project);
|
|
|
|
|
Assert.DoesNotContain("<TreatWarningsAsErrors>false</TreatWarningsAsErrors>", project);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 04:05:40 +02:00
|
|
|
[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);
|
2026-09-23 21:36:34 +02:00
|
|
|
// 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);
|
2026-09-21 04:05:40 +02:00
|
|
|
Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script);
|
2026-09-23 21:36:34 +02:00
|
|
|
Assert.DoesNotContain("if [[ -n \"${VOICECAT_BUILD_NUMBER:-}\" ]]; then", script);
|
2026-09-21 04:05:40 +02:00
|
|
|
|
|
|
|
|
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("<key>CFBundleVersion</key>", appManifest);
|
|
|
|
|
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", extensionManifest);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 21:36:34 +02:00
|
|
|
[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("<key>XSAppIconAssets</key>", manifest);
|
|
|
|
|
Assert.Contains("<string>Assets.xcassets/AppIcon.appiconset</string>", manifest);
|
|
|
|
|
Assert.Contains("AppIcon-1024.png", iconSet);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
[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("<string>screen-capture</string>", manifest);
|
|
|
|
|
Assert.Contains("if (IosScreenCapture.IsAvailable)", settings);
|
|
|
|
|
Assert.Contains("new RPSystemBroadcastPickerView", settings);
|
|
|
|
|
Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 21:47:42 +02:00
|
|
|
[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(5)", pump);
|
|
|
|
|
Assert.Contains("File.Exists(path)", pump);
|
|
|
|
|
Assert.DoesNotContain("using MemoryMappedFile", pump);
|
|
|
|
|
Assert.DoesNotContain("using MemoryMappedViewAccessor", pump);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
[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);
|
2026-09-23 21:37:24 +02:00
|
|
|
|
|
|
|
|
// 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);
|
2026-09-21 14:14:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-24 13:31:16 +02:00
|
|
|
[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);
|
|
|
|
|
Assert.Contains("IosAudioEngine.Shared.Reconfigure()", 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
[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("ApplyInputSelection(session)", StringComparison.Ordinal) <
|
|
|
|
|
router.IndexOf("SetActive(true", StringComparison.Ordinal));
|
|
|
|
|
Assert.DoesNotContain("session.SetPreferredInputNumberOfChannels", router);
|
|
|
|
|
Assert.DoesNotContain("MaximumInputNumberOfChannels", router);
|
2026-09-21 18:04:00 +02:00
|
|
|
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router);
|
|
|
|
|
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router);
|
|
|
|
|
Assert.Contains("AVAudioSession.PolarPatternStereo", router);
|
|
|
|
|
Assert.Contains("SupportsStereoPolarPattern", router);
|
2026-09-22 22:07:00 +02:00
|
|
|
Assert.Contains("AdaptivePcmBuffer Ring = new(channels, 120, 65_536)", engine);
|
2026-09-21 14:14:15 +02:00
|
|
|
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("ReferenceEquals(engine, next) && !next.Running", engine);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 01:15:52 +02:00
|
|
|
[Fact]
|
|
|
|
|
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
|
|
|
|
{
|
2026-09-21 00:11:01 +02:00
|
|
|
string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1");
|
2026-09-20 01:15:52 +02:00
|
|
|
|
|
|
|
|
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<string> 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<string> stdout = process.StandardOutput.ReadToEndAsync();
|
|
|
|
|
Task<string> 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);
|
2026-09-21 00:11:01 +02:00
|
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
|
2026-09-20 01:15:52 +02:00
|
|
|
directory = directory.Parent;
|
|
|
|
|
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
|
|
|
|
|
}
|
|
|
|
|
}
|