Files
voice-cat/tests/VoiceCat.Tests/PublishServerScriptTests.cs
T
Talon 1a0ff957ae
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
fix(ios): stop the speaker toggle rebuilding the audio graph in a loop
Toggling speaker output flipped the route back and forth indefinitely. Two
loops, both of which made a rebuild produce the condition for the next one.

Reconfiguring the session moves the route, and moving the route is reported
back through RouteChangeNotification. Forcing the speaker takes a headset out
of the route, which arrives as OldDeviceUnavailable, and releasing it brings
the headset back as NewDeviceAvailable; neither is among the reasons the
handler filters, so each rebuild answered its own echo with another rebuild.
Nothing compared the reported route against the route the live graph was
actually built on.

Record that route at the end of Apply, once the session is configured, and
rebuild only when a reported change differs from it; notifications that arrive
while Apply is still running describe the change Apply is itself making and are
ignored outright. The decision is AudioRouteWatcher in VoiceCat.Core, which is
platform-agnostic and tested, following ControlPathWatcher; the route identity
it compares is supplied by the caller, on iOS the UIDs of the current route's
ports. A graph whose route is unchanged but broken is still the stall
watchdog's to catch.

The port override was also re-asserted on every Apply, so where the system
wanted to hand output back to a connected headset each rebuild forced it to the
speaker again and the resulting route change drove the next rebuild. It is now
the one-shot request it should always have been, issued by the toggle alone;
the DefaultToSpeaker category option is the part that persists across rebuilds.

Also updates the route test from 724f7e9, which asserted the voice-chat preset
clearing the speaker flag and the absence of the port override. Both were
deliberately removed when speaker output became orthogonal to the preset, and
the test should have been updated with them.
2026-09-25 19:05:14 +02:00

295 lines
16 KiB
C#

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>$(WarningsNotAsErrors);IL2104</WarningsNotAsErrors>", project);
Assert.DoesNotContain("<TreatWarningsAsErrors>false</TreatWarningsAsErrors>", 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("<key>CFBundleVersion</key>", appManifest);
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", 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("<key>XSAppIconAssets</key>", manifest);
Assert.Contains("<string>Assets.xcassets/AppIcon.appiconset</string>", 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("<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);
}
[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);
}
[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);
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);
}
[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);
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("ReferenceEquals(engine, next) && !next.Running", 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);
// A route change that reports the route the live graph was built on is the echo of the
// Apply that produced it, and answering it rebuilds without end.
Assert.Contains("if (applying) return;", router);
Assert.Contains("!route.ShouldRebuild(RouteSignature(AVAudioSession.SharedInstance()))", router);
Assert.Contains("route.Built(RouteSignature(session))", router);
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<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);
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.");
}
}