Declare XSAppIconAssets in the app manifest so actool receives --app-icon and the bundle carries CFBundleIconName, and always pass both Xcode version placeholders to the ReplayKit extension so an empty build number cannot drop CFBundleVersion and fail installd.
206 lines
10 KiB
C#
206 lines
10 KiB
C#
using System.Diagnostics;
|
|
|
|
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 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);
|
|
}
|
|
|
|
[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 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.");
|
|
}
|
|
}
|