using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Services; using LanMountainDesktop.Shared.Contracts.Deployment; using Xunit; namespace LanMountainDesktop.Tests; /// /// 安装部署事务性行为的测试。 /// 覆盖:事务回滚、.partial 清理、过时部署清理、字符串字面量断言、ARP 注册/移除。 /// public sealed class InstallerDeploymentTransactionTests : IDisposable { private readonly string _tempRoot = Path.Combine( AppContext.BaseDirectory, "TestArtifacts", "LanMountainDesktop.Tests", nameof(InstallerDeploymentTransactionTests), Guid.NewGuid().ToString("N")); public void Dispose() { if (Directory.Exists(_tempRoot)) { try { Directory.Delete(_tempRoot, recursive: true); } catch { // 测试清理失败时忽略 } } } /// /// 事务性回滚:复制失败时应删除不完整的部署目录,保留预先存在的状态。 /// [Fact] public async Task InstallAsync_CopyFailure_RollsBackDeployment() { // Arrange: 准备包目录 var packageRoot = Path.Combine(_tempRoot, "Files"); var appRoot = Path.Combine(packageRoot, "app-1.0.0"); Directory.CreateDirectory(appRoot); File.WriteAllText(Path.Combine(appRoot, DeploymentLayout.GetHostExecutableName()), "host"); // 创建一个可锁定的文件,模拟复制失败 var lockableFile = Path.Combine(appRoot, "lockable.txt"); File.WriteAllText(lockableFile, "content"); var package = new PreparedFilesPackage( "1.0.0", "test", Path.Combine(_tempRoot, "Files.zip"), packageRoot, CreateManifest("1.0.0")); var installPath = Path.Combine(_tempRoot, "install"); var launcherRoot = Path.Combine(installPath, InstallerPathGuard.ApplicationDirectoryName); Directory.CreateDirectory(launcherRoot); // 预先存在的文件,验证不会被破坏 var preExistingFile = Path.Combine(launcherRoot, "pre-existing.txt"); File.WriteAllText(preExistingFile, "original"); // Act & Assert: 安装应失败(因为源文件被锁定) using var lockHandle = File.Open(lockableFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None); var ex = await Assert.ThrowsAsync( () => new FilesPackageInstaller().InstallAsync(package, launcherRoot, null, CancellationToken.None)); Assert.Contains("安装失败", ex.Message); // 验证:不完整的部署目录应被清理 var deploymentDirs = Directory.Exists(launcherRoot) ? Directory.GetDirectories(launcherRoot, $"{DeploymentLayout.DeploymentDirectoryPrefix}*") : Array.Empty(); Assert.Empty(deploymentDirs); // 验证:预先存在的文件应保持不变 Assert.True(File.Exists(preExistingFile)); Assert.Equal("original", File.ReadAllText(preExistingFile)); lockHandle.Close(); } /// /// 事务性回滚:失败时 .partial 标记应被清理(通过锁定源文件注入复制失败)。 /// [Fact] public async Task InstallAsync_PartialMarker_CleanedUpOnFailure() { // Arrange: 准备包目录,并锁定其中一个源文件以注入复制失败 var packageRoot = Path.Combine(_tempRoot, "Files"); var appRoot = Path.Combine(packageRoot, "app-1.0.0"); Directory.CreateDirectory(appRoot); File.WriteAllText(Path.Combine(appRoot, DeploymentLayout.GetHostExecutableName()), "host"); var lockedFile = Path.Combine(appRoot, "locked.txt"); File.WriteAllText(lockedFile, "extra"); var package = new PreparedFilesPackage( "1.0.0", "test", Path.Combine(_tempRoot, "Files.zip"), packageRoot, CreateManifest("1.0.0")); var installPath = Path.Combine(_tempRoot, "install"); var launcherRoot = Path.Combine(installPath, InstallerPathGuard.ApplicationDirectoryName); Directory.CreateDirectory(launcherRoot); // Act & Assert: 安装应失败(源文件被独占锁定,无法读取) using var lockHandle = File.Open(lockedFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None); var ex = await Assert.ThrowsAsync( () => new FilesPackageInstaller().InstallAsync(package, launcherRoot, null, CancellationToken.None)); Assert.Contains("安装失败", ex.Message); // 验证:不应有任何部署目录残留(.partial 部署已被整体清理) if (Directory.Exists(launcherRoot)) { var deploymentDirs = Directory.GetDirectories(launcherRoot, $"{DeploymentLayout.DeploymentDirectoryPrefix}*"); Assert.Empty(deploymentDirs); } } /// /// 过时部署清理:保留最新一个作为回滚,删除更旧的。 /// [Fact] public void CleanupStaleDeployments_KeepsNewestOneAsRollback() { // Arrange: 创建模拟的部署目录 var launcherRoot = Path.Combine(_tempRoot, "launcher"); Directory.CreateDirectory(launcherRoot); // 创建旧的部署目录(无 .current 标记) var oldDeployment = Path.Combine(launcherRoot, "app-1.0.0-0"); Directory.CreateDirectory(oldDeployment); File.WriteAllText(Path.Combine(oldDeployment, "old.txt"), "old"); // 创建新的部署目录(无 .current 标记) var newDeployment = Path.Combine(launcherRoot, "app-2.0.0-0"); Directory.CreateDirectory(newDeployment); File.WriteAllText(Path.Combine(newDeployment, "new.txt"), "new"); // 设置较新的写入时间 Directory.SetLastWriteTimeUtc(newDeployment, DateTime.UtcNow); // 创建活动部署(有 .current 标记) var activeDeployment = Path.Combine(launcherRoot, "app-1.5.0-0"); Directory.CreateDirectory(activeDeployment); File.WriteAllText(Path.Combine(activeDeployment, DeploymentLayout.CurrentMarkerFileName), string.Empty); // Act FilesPackageInstaller.CleanupStaleDeployments(launcherRoot); // Assert: 活动部署应保留 Assert.True(Directory.Exists(activeDeployment)); // Assert: 新的部署目录应保留(作为回滚) Assert.True(Directory.Exists(newDeployment)); // Assert: 旧的部署目录应被删除 Assert.False(Directory.Exists(oldDeployment)); } /// /// 过时部署清理:删除失败(目录被锁定)的部署应写入 .destroy 标记。 /// 布局:活动部署 + 回滚部署(保留)+ 被锁定的最旧部署(删除失败 → .destroy)。 /// [Fact] public void CleanupStaleDeployments_LockedDirGetsDestroyMarker() { // Arrange: 创建模拟的部署目录 var launcherRoot = Path.Combine(_tempRoot, "launcher"); Directory.CreateDirectory(launcherRoot); // 最旧部署(将被锁定,删除失败) var lockedDeployment = Path.Combine(launcherRoot, "app-1.0.0-0"); Directory.CreateDirectory(lockedDeployment); var lockedFile = Path.Combine(lockedDeployment, "locked.dll"); File.WriteAllText(lockedFile, "x"); Directory.SetLastWriteTimeUtc(lockedDeployment, DateTime.UtcNow.AddDays(-2)); // 回滚部署(无 .current,最新的非活动部署 → 保留) var rollbackDeployment = Path.Combine(launcherRoot, "app-1.5.0-0"); Directory.CreateDirectory(rollbackDeployment); Directory.SetLastWriteTimeUtc(rollbackDeployment, DateTime.UtcNow.AddDays(-1)); // 活动部署 var activeDeployment = Path.Combine(launcherRoot, "app-2.0.0-0"); Directory.CreateDirectory(activeDeployment); File.WriteAllText(Path.Combine(activeDeployment, DeploymentLayout.CurrentMarkerFileName), string.Empty); // Act: 锁定最旧部署中的文件后执行清理 using (File.Open(lockedFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { FilesPackageInstaller.CleanupStaleDeployments(launcherRoot); } // Assert: 活动部署与回滚部署保留 Assert.True(Directory.Exists(activeDeployment)); Assert.True(Directory.Exists(rollbackDeployment)); // 被锁定的部署删除失败 → 应残留且带 .destroy 标记 Assert.True(Directory.Exists(lockedDeployment)); Assert.True(File.Exists(Path.Combine(lockedDeployment, DeploymentLayout.DestroyMarkerFileName))); } /// /// 源代码断言:FilesPackageInstaller.cs 中不应包含 ".current" 字符串字面量。 /// 应使用 DeploymentLayout.CurrentMarkerFileName 代替。 /// [Fact] public void FilesPackageInstaller_ShouldNotContainDotCurrentLiteral() { var installerProjectDir = Path.GetFullPath( Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "LanDesktopPLONDS.installer")); var filesPackageInstallerPath = Path.Combine(installerProjectDir, "Services", "FilesPackageInstaller.cs"); Assert.True(File.Exists(filesPackageInstallerPath), $"FilesPackageInstaller.cs 未找到:{filesPackageInstallerPath}"); var source = File.ReadAllText(filesPackageInstallerPath); // 排除注释行和字符串中的引用,只检查代码中的直接使用 var lines = source.Split('\n'); var violations = new List(); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; var trimmed = line.Trim(); // 跳过注释行 if (trimmed.StartsWith("//") || trimmed.StartsWith("///") || trimmed.StartsWith("*")) { continue; } // 检查是否有直接的 ".current" 字符串字面量 // 允许 DeploymentLayout.CurrentMarkerFileName 的使用 if (trimmed.Contains("\".current\"") && !trimmed.Contains("DeploymentLayout.CurrentMarkerFileName")) { violations.Add($"行 {i + 1}: {trimmed}"); } } Assert.Empty(violations); } /// /// 源代码断言:FilesPackageInstaller.cs 中不应包含 ".partial" 或 ".destroy" 字符串字面量。 /// 应使用 DeploymentLayout 常量代替。 /// [Fact] public void FilesPackageInstaller_ShouldNotContainMarkerLiterals() { var installerProjectDir = Path.GetFullPath( Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "LanDesktopPLONDS.installer")); var filesPackageInstallerPath = Path.Combine(installerProjectDir, "Services", "FilesPackageInstaller.cs"); Assert.True(File.Exists(filesPackageInstallerPath), $"FilesPackageInstaller.cs 未找到:{filesPackageInstallerPath}"); var source = File.ReadAllText(filesPackageInstallerPath); var lines = source.Split('\n'); var violations = new List(); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; var trimmed = line.Trim(); if (trimmed.StartsWith("//") || trimmed.StartsWith("///") || trimmed.StartsWith("*")) { continue; } if (trimmed.Contains("\".partial\"") && !trimmed.Contains("DeploymentLayout.PartialMarkerFileName")) { violations.Add($"行 {i + 1} (.partial): {trimmed}"); } if (trimmed.Contains("\".destroy\"") && !trimmed.Contains("DeploymentLayout.DestroyMarkerFileName")) { violations.Add($"行 {i + 1} (.destroy): {trimmed}"); } } Assert.Empty(violations); } /// /// ARP 注册:使用注入的测试子键写入和移除注册表条目。 /// 仅在 Windows 上运行。 /// [Fact] public void ArpRegistration_RegisterAndRemove_UsesInjectedSubKey() { if (!OperatingSystem.IsWindows()) { return; } var testBase = @"Software\LanMountainDesktopTests"; var launcherRoot = Path.Combine(_tempRoot, "launcher"); Directory.CreateDirectory(launcherRoot); var launcherExePath = Path.Combine(launcherRoot, DeploymentLayout.GetLauncherExecutableName()); File.WriteAllText(launcherExePath, "mock launcher"); try { // Act: 注册 ArpRegistration.Register(launcherRoot, "1.0.0", testBase); // Assert: 注册表键应存在 var subKeyPath = ArpRegistration.GetUninstallSubKeyPath(testBase); using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(subKeyPath); if (key != null) { Assert.Equal("阑山桌面", key.GetValue("DisplayName")); Assert.Equal("1.0.0", key.GetValue("DisplayVersion")); Assert.Equal("LanMountain", key.GetValue("Publisher")); Assert.Equal(1, key.GetValue("NoModify")); Assert.Equal(1, key.GetValue("NoRepair")); } else { // HKLM 写入可能需要管理员权限,尝试 HKCU using var userKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(subKeyPath); if (userKey != null) { Assert.Equal("阑山桌面", userKey.GetValue("DisplayName")); Assert.Equal("1.0.0", userKey.GetValue("DisplayVersion")); } } // Act: 移除 ArpRegistration.Remove(testBase); // Assert: 注册表键应不存在 using var keyAfterRemove = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(subKeyPath); using var userKeyAfterRemove = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(subKeyPath); Assert.Null(keyAfterRemove); Assert.Null(userKeyAfterRemove); } finally { // 清理:确保测试注册表键被删除 try { Microsoft.Win32.Registry.LocalMachine.DeleteSubKeyTree( ArpRegistration.GetUninstallSubKeyPath(testBase), throwOnMissingSubKey: false); Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree( ArpRegistration.GetUninstallSubKeyPath(testBase), throwOnMissingSubKey: false); } catch { // 清理失败时忽略 } } } /// /// RunningProcessGuard:空安装路径不抛出异常。 /// [Fact] public void RunningProcessGuard_EmptyPath_DoesNotThrow() { // 对于非 Windows 平台或空目录,不应抛出异常 var emptyDir = Path.Combine(_tempRoot, "empty"); Directory.CreateDirectory(emptyDir); // 不应抛出异常 var processes = RunningProcessGuard.FindRunningProcesses(emptyDir); Assert.NotNull(processes); } /// /// 成功安装后应有 .current 标记且无 .partial 标记。 /// [Fact] public async Task InstallAsync_Success_CreatesCurrentAndNoPartial() { // Arrange: 准备包目录 var packageRoot = Path.Combine(_tempRoot, "Files"); var appRoot = Path.Combine(packageRoot, "app-3.0.0"); Directory.CreateDirectory(appRoot); File.WriteAllText(Path.Combine(appRoot, DeploymentLayout.GetHostExecutableName()), "host"); File.WriteAllText(Path.Combine(appRoot, "data.txt"), "data"); var package = new PreparedFilesPackage( "3.0.0", "test", Path.Combine(_tempRoot, "Files.zip"), packageRoot, CreateManifest("3.0.0")); var installPath = Path.Combine(_tempRoot, "install"); var launcherRoot = Path.Combine(installPath, InstallerPathGuard.ApplicationDirectoryName); // Act await new FilesPackageInstaller().InstallAsync(package, launcherRoot, null, CancellationToken.None); // Assert: 应有 .current 标记 var deploymentDir = Path.Combine(launcherRoot, "app-3.0.0-0"); Assert.True(File.Exists(Path.Combine(deploymentDir, DeploymentLayout.CurrentMarkerFileName))); // Assert: 不应有 .partial 标记 Assert.False(File.Exists(Path.Combine(deploymentDir, DeploymentLayout.PartialMarkerFileName))); // Assert: 主程序文件应存在 Assert.True(File.Exists(Path.Combine(deploymentDir, DeploymentLayout.GetHostExecutableName()))); } private static InstallerPlondsManifest CreateManifest(string version = "1.0.0") { return new InstallerPlondsManifest( "1", version, "0.9.0", true, false, "stable", "windows-x64", DateTimeOffset.UtcNow, new Dictionary(), new Dictionary(), new Dictionary(), null, null); } }