diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionSyncService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionSyncService.cs new file mode 100644 index 00000000..621d5f98 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionSyncService.cs @@ -0,0 +1,18 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +public sealed record SolutionSyncOptions( + string SolutionUniqueName, + string SolutionRootPath, + string? ProjectFilePath); + +public sealed record SolutionSyncResult( + string SolutionRootPath, + IReadOnlyList NormalizedAssemblies, + IReadOnlyList ExcludedBinaries, + IReadOnlyList ExcludedWebResources, + IReadOnlyList RemovedFiles); + +public interface ISolutionSyncService +{ + Task SyncAsync(string? profileName, SolutionSyncOptions options, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Resolution/ProjectReferenceReader.cs b/src/TALXIS.CLI.Core/Resolution/ProjectReferenceReader.cs new file mode 100644 index 00000000..633e39d1 --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/ProjectReferenceReader.cs @@ -0,0 +1,94 @@ +using System.Xml.Linq; + +namespace TALXIS.CLI.Core.Resolution; + +public static class ProjectReferenceReader +{ + private static readonly HashSet PluginProjectTypes = + new(StringComparer.OrdinalIgnoreCase) { "Plugin", "WorkflowActivity" }; + + public static IReadOnlyCollection ReadPluginAssemblyNames(string projectFilePath) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var referenced in EnumerateReferencedProjects(projectFilePath)) + { + var info = ReadProjectInfo(referenced); + if (info.ProjectType is not null && PluginProjectTypes.Contains(info.ProjectType)) + result.Add(info.AssemblyName); + } + return result; + } + + public static IReadOnlyCollection ReadScriptLibraryWebResourceNames(string solutionProjectFilePath) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + var publisherPrefix = ReadProperty(solutionProjectFilePath, "PublisherPrefix"); + if (string.IsNullOrWhiteSpace(publisherPrefix)) + return result; + + foreach (var referenced in EnumerateReferencedProjects(solutionProjectFilePath)) + { + var info = ReadProjectInfo(referenced); + if (string.Equals(info.ProjectType, "ScriptLibrary", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(info.ScriptLibraryName)) + result.Add(info.ScriptLibraryName.StartsWith(publisherPrefix) ? $"{info.ScriptLibraryName}.js" : $"{publisherPrefix}_{info.ScriptLibraryName}.js"); + } + return result; + } + + private static IEnumerable EnumerateReferencedProjects(string projectFilePath) + { + if (!File.Exists(projectFilePath)) + yield break; + + var projectDir = Path.GetDirectoryName(Path.GetFullPath(projectFilePath))!; + var doc = XDocument.Load(projectFilePath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + + foreach (var reference in doc.Descendants(ns + "ProjectReference")) + { + var include = reference.Attribute("Include")?.Value; + if (string.IsNullOrWhiteSpace(include)) + continue; + + var relative = include.Replace('\\', Path.DirectorySeparatorChar); + var referencedPath = Path.GetFullPath(Path.Combine(projectDir, relative)); + if (File.Exists(referencedPath)) + yield return referencedPath; + } + } + + private static (string? ProjectType, string AssemblyName, string? ScriptLibraryName) ReadProjectInfo(string referencedProjectPath) + { + var fallbackName = Path.GetFileNameWithoutExtension(referencedProjectPath); + try + { + var doc = XDocument.Load(referencedProjectPath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var projectType = doc.Descendants(ns + "ProjectType").FirstOrDefault()?.Value?.Trim(); + var assemblyName = doc.Descendants(ns + "AssemblyName").FirstOrDefault()?.Value?.Trim(); + var scriptLibraryName = doc.Descendants(ns + "ScriptLibraryName").FirstOrDefault()?.Value?.Trim(); + return (projectType, string.IsNullOrWhiteSpace(assemblyName) ? fallbackName : assemblyName, scriptLibraryName); + } + catch (System.Xml.XmlException) + { + return (null, fallbackName, null); + } + } + + private static string? ReadProperty(string projectFilePath, string propertyName) + { + if (!File.Exists(projectFilePath)) + return null; + try + { + var doc = XDocument.Load(projectFilePath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + return doc.Descendants(ns + propertyName).FirstOrDefault()?.Value?.Trim(); + } + catch (System.Xml.XmlException) + { + return null; + } + } +} diff --git a/src/TALXIS.CLI.Core/Resolution/SolutionSyncMerge.cs b/src/TALXIS.CLI.Core/Resolution/SolutionSyncMerge.cs new file mode 100644 index 00000000..261f2574 --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/SolutionSyncMerge.cs @@ -0,0 +1,55 @@ +namespace TALXIS.CLI.Core.Resolution; + +public static class SolutionSyncMerge +{ + // Mirrors each top-level folder of the export into the solution root. Top-level entries the + // export doesn't contain (project file, bin, obj, ...) are left alone so they can't be deleted. + public static IReadOnlyList Merge(string fromRoot, string intoRoot) + { + var deleted = new List(); + Directory.CreateDirectory(intoRoot); + + foreach (var file in Directory.GetFiles(fromRoot)) + File.Copy(file, Path.Combine(intoRoot, Path.GetFileName(file)), overwrite: true); + + foreach (var dir in Directory.GetDirectories(fromRoot)) + MirrorDirectory(dir, Path.Combine(intoRoot, Path.GetFileName(dir)), intoRoot, deleted); + + return deleted; + } + + private static void MirrorDirectory(string from, string into, string intoRoot, List deleted) + { + Directory.CreateDirectory(into); + + var fromFiles = new HashSet( + Directory.GetFiles(from).Select(Path.GetFileName)!, StringComparer.OrdinalIgnoreCase); + var fromDirs = new HashSet( + Directory.GetDirectories(from).Select(Path.GetFileName)!, StringComparer.OrdinalIgnoreCase); + + foreach (var file in Directory.GetFiles(from)) + File.Copy(file, Path.Combine(into, Path.GetFileName(file)), overwrite: true); + + foreach (var sub in Directory.GetDirectories(from)) + MirrorDirectory(sub, Path.Combine(into, Path.GetFileName(sub)), intoRoot, deleted); + + foreach (var file in Directory.GetFiles(into)) + { + if (!fromFiles.Contains(Path.GetFileName(file))) + { + deleted.Add(Path.GetRelativePath(intoRoot, file)); + File.Delete(file); + } + } + + foreach (var sub in Directory.GetDirectories(into)) + { + if (!fromDirs.Contains(Path.GetFileName(sub))) + { + foreach (var f in Directory.GetFiles(sub, "*", SearchOption.AllDirectories)) + deleted.Add(Path.GetRelativePath(intoRoot, f)); + Directory.Delete(sub, recursive: true); + } + } + } +} diff --git a/src/TALXIS.CLI.Core/Resolution/SolutionSyncTransform.cs b/src/TALXIS.CLI.Core/Resolution/SolutionSyncTransform.cs new file mode 100644 index 00000000..13601cb4 --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/SolutionSyncTransform.cs @@ -0,0 +1,142 @@ +using System.Xml.Linq; + +namespace TALXIS.CLI.Core.Resolution; + +public static class SolutionSyncTransform +{ + private const string PluginAssembliesDir = "PluginAssemblies"; + private const string WebResourcesDir = "WebResources"; + private const string DataXmlSuffix = ".data.xml"; + + public static IReadOnlyList NormalizePluginAssemblyPaths(string unpackedRoot) + { + var normalized = new List(); + var pluginsDir = Path.Combine(unpackedRoot, PluginAssembliesDir); + if (!Directory.Exists(pluginsDir)) + return normalized; + + foreach (var nestedDir in Directory.GetDirectories(pluginsDir)) + { + foreach (var file in Directory.GetFiles(nestedDir)) + { + var fileName = Path.GetFileName(file); + var destination = Path.Combine(pluginsDir, fileName); + + if (File.Exists(destination)) + File.Delete(destination); + File.Move(file, destination); + + if (fileName.EndsWith(DataXmlSuffix, StringComparison.OrdinalIgnoreCase)) + { + var assemblyFileName = fileName[..^DataXmlSuffix.Length]; + RewriteFileName(destination, $"/{PluginAssembliesDir}/{assemblyFileName}"); + normalized.Add(assemblyFileName); + } + } + + if (!Directory.EnumerateFileSystemEntries(nestedDir).Any()) + Directory.Delete(nestedDir); + } + + return normalized; + } + + public static IReadOnlyList ExcludeProjectReferenceBinaries( + string unpackedRoot, + IReadOnlyCollection referencedAssemblyNames) + { + var excluded = new List(); + var pluginsDir = Path.Combine(unpackedRoot, PluginAssembliesDir); + if (!Directory.Exists(pluginsDir) || referencedAssemblyNames.Count == 0) + return excluded; + + foreach (var dataXml in Directory.GetFiles(pluginsDir, "*" + DataXmlSuffix)) + { + var assemblySimpleName = ReadAssemblySimpleName(dataXml); + if (assemblySimpleName is null || !MatchesReference(assemblySimpleName, referencedAssemblyNames)) + continue; + + var dllName = Path.GetFileName(dataXml)[..^DataXmlSuffix.Length]; + var dllPath = Path.Combine(pluginsDir, dllName); + if (File.Exists(dllPath)) + { + File.Delete(dllPath); + excluded.Add(dllName); + } + } + + return excluded; + } + + public static IReadOnlyList ExcludeScriptLibraryWebResources( + string unpackedRoot, + IReadOnlyCollection webResourceNames) + { + var excluded = new List(); + var webResDir = Path.Combine(unpackedRoot, WebResourcesDir); + if (!Directory.Exists(webResDir) || webResourceNames.Count == 0) + return excluded; + + foreach (var dataXml in Directory.GetFiles(webResDir, "*" + DataXmlSuffix)) + { + var resourceName = Path.GetFileName(dataXml)[..^DataXmlSuffix.Length]; + if (!webResourceNames.Contains(resourceName)) + continue; + + var contentPath = Path.Combine(webResDir, resourceName); + if (File.Exists(contentPath)) + { + File.Delete(contentPath); + excluded.Add(resourceName); + } + } + + return excluded; + } + + private static string? ReadAssemblySimpleName(string dataXmlPath) + { + XDocument doc; + try + { + doc = XDocument.Load(dataXmlPath); + } + catch (System.Xml.XmlException) + { + return null; + } + + var fullName = doc.Root?.Attribute("FullName")?.Value; + if (string.IsNullOrWhiteSpace(fullName)) + return null; + + var comma = fullName.IndexOf(','); + return (comma >= 0 ? fullName[..comma] : fullName).Trim(); + } + + private static bool MatchesReference(string assemblySimpleName, IReadOnlyCollection referencedNames) + { + foreach (var name in referencedNames) + { + if (assemblySimpleName.Equals(name, StringComparison.OrdinalIgnoreCase)) + return true; + if (assemblySimpleName.EndsWith("." + name, StringComparison.OrdinalIgnoreCase)) + return true; + if (name.EndsWith("." + assemblySimpleName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static void RewriteFileName(string dataXmlPath, string newFileName) + { + var doc = XDocument.Load(dataXmlPath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var fileNameElement = doc.Descendants(ns + "FileName").FirstOrDefault(); + if (fileNameElement is null || fileNameElement.Value == newFileName) + return; + + fileNameElement.Value = newFileName; + doc.Save(dataXmlPath); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs index b195897f..0d800d3b 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment.Solution; Name = "solution", Alias = "sln", Description = "Manage solutions in the target environment.", - Children = new[] { typeof(SolutionImportCliCommand), typeof(SolutionUninstallCliCommand), typeof(SolutionDeleteCliCommand), typeof(SolutionListCliCommand), typeof(SolutionGetCliCommand), typeof(SolutionCreateCliCommand), typeof(SolutionExportCliCommand), typeof(SolutionPackCliCommand), typeof(SolutionUnpackCliCommand), typeof(SolutionPublishCliCommand), typeof(SolutionUninstallCheckCliCommand), typeof(Component.SolutionComponentCliCommand) }, + Children = new[] { typeof(SolutionImportCliCommand), typeof(SolutionUninstallCliCommand), typeof(SolutionDeleteCliCommand), typeof(SolutionListCliCommand), typeof(SolutionGetCliCommand), typeof(SolutionCreateCliCommand), typeof(SolutionExportCliCommand), typeof(SolutionSyncCliCommand), typeof(SolutionPackCliCommand), typeof(SolutionUnpackCliCommand), typeof(SolutionPublishCliCommand), typeof(SolutionUninstallCheckCliCommand), typeof(Component.SolutionComponentCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class SolutionCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionSyncCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionSyncCliCommand.cs new file mode 100644 index 00000000..19785b79 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionSyncCliCommand.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Solution; + +[CliIdempotent] +[CliLongRunning] +[CliCommand( + Name = "sync", + Description = "Sync a solution from the LIVE environment into the local source project, normalizing plugin-assembly paths and skipping binaries built from project references. Requires an active profile." +)] +public class SolutionSyncCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(SolutionSyncCliCommand)); + + [CliArgument(Name = "project", Description = "Project directory (.cdsproj/.csproj) to sync into. Defaults to current directory. A bare solution unique name is also accepted, but project-reference binary exclusion then needs --output.")] + [DefaultValue(".")] + public string Project { get; set; } = "."; + + [CliOption(Name = "--output", Alias = "-o", Description = "Solution root folder to sync into. Overrides the project's SolutionRootPath. When neither is given, the project folder itself is used.", Required = false)] + public string? Output { get; set; } + + protected override async Task ExecuteAsync() + { + var resolved = Resolve(); + if (resolved is null) + return ExitValidationError; + + var (solutionName, solutionRoot, projectFile) = resolved.Value; + + var options = new SolutionSyncOptions(solutionName, solutionRoot, projectFile); + var service = TxcServices.Get(); + var result = await service.SyncAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); + + var payload = new + { + status = "synced", + solution = solutionName, + path = result.SolutionRootPath, + normalizedAssemblies = result.NormalizedAssemblies, + excludedBinaries = result.ExcludedBinaries, + excludedWebResources = result.ExcludedWebResources, + removedFiles = result.RemovedFiles, + }; + + OutputFormatter.WriteData(payload, _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Synced solution '{solutionName}' → {result.SolutionRootPath}"); + WriteList("Normalized plugin assembly path(s)", result.NormalizedAssemblies); + WriteList("Excluded project-reference binary(ies)", result.ExcludedBinaries); + WriteList("Excluded script-library web resource(s)", result.ExcludedWebResources); + WriteList("Removed stale solution file(s)", result.RemovedFiles); + + static void WriteList(string label, IReadOnlyList items) + { + if (items.Count == 0) + return; + OutputWriter.WriteLine($"{label} ({items.Count}):"); + foreach (var item in items) + OutputWriter.WriteLine($" - {item}"); + } +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } + + private (string SolutionName, string SolutionRoot, string? ProjectFile)? Resolve() + { + if (!IsDirectoryPath(Project)) + { + if (string.IsNullOrWhiteSpace(Output)) + { + Logger.LogError("A bare solution name requires --output to specify the solution root folder."); + return null; + } + return (Project, Path.GetFullPath(Output), null); + } + + var dirPath = Path.GetFullPath(Project); + if (!Directory.Exists(dirPath)) + { + Logger.LogError("Directory not found: {Path}.", dirPath); + return null; + } + + var projectFile = SolutionProjectResolver.FindProjectFile(dirPath); + if (projectFile is null) + { + Logger.LogError("No .cdsproj or .csproj found in '{Dir}'.", dirPath); + return null; + } + + var resolvedRoot = ResolveSolutionRoot(dirPath, projectFile); + if (resolvedRoot is null) + return null; + + var uniqueName = SolutionProjectResolver.ReadSolutionUniqueName(resolvedRoot); + if (string.IsNullOrWhiteSpace(uniqueName)) + { + Logger.LogError("Could not read from '{Path}'.", Path.Combine(resolvedRoot, "Other", "Solution.xml")); + return null; + } + + Logger.LogInformation("Resolved solution '{UniqueName}' from project directory.", uniqueName); + return (uniqueName, resolvedRoot, projectFile); + } + + private string? ResolveSolutionRoot(string dirPath, string projectFile) + { + if (Output is not null) + return Path.GetFullPath(Output); + + var declared = SolutionProjectResolver.ReadSolutionRootPath(projectFile); + if (string.IsNullOrWhiteSpace(declared)) + return dirPath; + + var resolved = SolutionProjectResolver.ResolveSolutionRoot(projectFile); + if (resolved is null) + Logger.LogError("Solution root path '{SolutionRootPath}' (from project) does not exist.", declared); + return resolved; + } + + private static bool IsDirectoryPath(string value) + { + if (value == ".") + return true; + if (value.Contains('/') || value.Contains('\\')) + return true; + if (Directory.Exists(value)) + return true; + return false; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs index 5731793c..bba0156f 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection AddTxcDataverseApplication(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionSyncService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionSyncService.cs new file mode 100644 index 00000000..4329fcbb --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionSyncService.cs @@ -0,0 +1,62 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using TALXIS.CLI.Platform.Dataverse.Runtime; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +internal sealed class DataverseSolutionSyncService : ISolutionSyncService +{ + private readonly ISolutionPackagerService _packager; + + public DataverseSolutionSyncService(ISolutionPackagerService packager) + { + _packager = packager; + } + + public async Task SyncAsync( + string? profileName, + SolutionSyncOptions options, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + + var zipBytes = await SolutionExporter.ExportAsync( + conn.Client, options.SolutionUniqueName, managed: false, ct).ConfigureAwait(false); + + var tempZip = Path.Combine(Path.GetTempPath(), $"txc_sync_{Guid.NewGuid():N}.zip"); + var stagingRoot = Path.Combine(Path.GetTempPath(), $"txc_sync_{Guid.NewGuid():N}"); + try + { + await File.WriteAllBytesAsync(tempZip, zipBytes, ct).ConfigureAwait(false); + + Directory.CreateDirectory(stagingRoot); + _packager.Unpack(tempZip, stagingRoot, managed: false); + + var normalized = SolutionSyncTransform.NormalizePluginAssemblyPaths(stagingRoot); + + IReadOnlyList excluded = []; + IReadOnlyList excludedWebResources = []; + if (options.ProjectFilePath is not null) + { + var referencedAssemblies = ProjectReferenceReader.ReadPluginAssemblyNames(options.ProjectFilePath); + excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(stagingRoot, referencedAssemblies); + + var scriptLibraryWebResources = ProjectReferenceReader.ReadScriptLibraryWebResourceNames(options.ProjectFilePath); + excludedWebResources = SolutionSyncTransform.ExcludeScriptLibraryWebResources(stagingRoot, scriptLibraryWebResources); + } + + Directory.CreateDirectory(options.SolutionRootPath); + var removed = SolutionSyncMerge.Merge(stagingRoot, options.SolutionRootPath); + + return new SolutionSyncResult(options.SolutionRootPath, normalized, excluded, excludedWebResources, removed); + } + finally + { + if (File.Exists(tempZip)) + File.Delete(tempZip); + if (Directory.Exists(stagingRoot)) + Directory.Delete(stagingRoot, recursive: true); + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ProjectReferenceReaderTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ProjectReferenceReaderTests.cs new file mode 100644 index 00000000..77b5fd2c --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ProjectReferenceReaderTests.cs @@ -0,0 +1,142 @@ +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class ProjectReferenceReaderTests : IDisposable +{ + private readonly string _root; + + public ProjectReferenceReaderTests() + { + _root = Path.Combine(Path.GetTempPath(), "txc_projref_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + private string WriteReferencedProject(string folder, string fileName, string innerXml) + { + var dir = Path.Combine(_root, folder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, fileName); + File.WriteAllText(path, $"{innerXml}"); + return path; + } + + private string WriteSolution(params string[] includes) + { + var refs = string.Concat(includes.Select(i => $"")); + var path = Path.Combine(_root, "Solution.cdsproj"); + File.WriteAllText(path, $"{refs}"); + return path; + } + + private string WriteSolutionWithPrefix(string publisherPrefix, params string[] includes) + { + var refs = string.Concat(includes.Select(i => $"")); + var path = Path.Combine(_root, "Solution.cdsproj"); + File.WriteAllText(path, $"{publisherPrefix}{refs}"); + return path; + } + + [Fact] + public void Reads_AssemblyName_ForPluginProject() + { + WriteReferencedProject("Logic", "Logic.csproj", "PluginAcme.Apps.MoveOrder.Logic"); + var cdsproj = WriteSolution("Logic\\Logic.csproj"); + + var names = ProjectReferenceReader.ReadPluginAssemblyNames(cdsproj); + + Assert.Contains("Acme.Apps.MoveOrder.Logic", names); + } + + [Fact] + public void Includes_WorkflowActivityProject() + { + WriteReferencedProject("Wf", "Wf.csproj", "WorkflowActivityAcme.Wf"); + var cdsproj = WriteSolution("Wf\\Wf.csproj"); + + var names = ProjectReferenceReader.ReadPluginAssemblyNames(cdsproj); + + Assert.Contains("Acme.Wf", names); + } + + [Fact] + public void Ignores_NonPluginProjectTypes() + { + WriteReferencedProject("Lib", "Lib.csproj", "Shared.Lib"); + WriteReferencedProject("Script", "Script.csproj", "ScriptLibraryScripts"); + var cdsproj = WriteSolution("Lib\\Lib.csproj", "Script\\Script.csproj"); + + var names = ProjectReferenceReader.ReadPluginAssemblyNames(cdsproj); + + Assert.Empty(names); + } + + [Fact] + public void FallsBackTo_ProjectFileName_WhenNoAssemblyName() + { + WriteReferencedProject("Logic", "MyPlugin.csproj", "Plugin"); + var cdsproj = WriteSolution("Logic\\MyPlugin.csproj"); + + var names = ProjectReferenceReader.ReadPluginAssemblyNames(cdsproj); + + Assert.Contains("MyPlugin", names); + } + + [Fact] + public void Returns_Empty_WhenNoProjectReferences() + { + var cdsproj = Path.Combine(_root, "Solution.cdsproj"); + File.WriteAllText(cdsproj, ""); + + var names = ProjectReferenceReader.ReadPluginAssemblyNames(cdsproj); + + Assert.Empty(names); + } + + [Fact] + public void Returns_Empty_WhenProjectMissing() + { + var names = ProjectReferenceReader.ReadPluginAssemblyNames(Path.Combine(_root, "nope.cdsproj")); + Assert.Empty(names); + } + + [Fact] + public void ScriptLibrary_BuildsWebResourceName_FromSolutionPrefixAndScriptLibraryName() + { + WriteReferencedProject("Scripts", "Scripts.csproj", "ScriptLibrarymain"); + var cdsproj = WriteSolutionWithPrefix("udpp", "Scripts\\Scripts.csproj"); + + var names = ProjectReferenceReader.ReadScriptLibraryWebResourceNames(cdsproj); + + Assert.Contains("udpp_main.js", names); + } + + [Fact] + public void ScriptLibrary_Ignores_NonScriptLibraryProjects() + { + WriteReferencedProject("Logic", "Logic.csproj", "PluginLogic"); + var cdsproj = WriteSolutionWithPrefix("udpp", "Logic\\Logic.csproj"); + + var names = ProjectReferenceReader.ReadScriptLibraryWebResourceNames(cdsproj); + + Assert.Empty(names); + } + + [Fact] + public void ScriptLibrary_Empty_WhenSolutionHasNoPublisherPrefix() + { + WriteReferencedProject("Scripts", "Scripts.csproj", "ScriptLibrarymain"); + var cdsproj = WriteSolution("Scripts\\Scripts.csproj"); + + var names = ProjectReferenceReader.ReadScriptLibraryWebResourceNames(cdsproj); + + Assert.Empty(names); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncMergeTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncMergeTests.cs new file mode 100644 index 00000000..6b2c7588 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncMergeTests.cs @@ -0,0 +1,81 @@ +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class SolutionSyncMergeTests : IDisposable +{ + private readonly string _from; + private readonly string _into; + + public SolutionSyncMergeTests() + { + var baseDir = Path.Combine(Path.GetTempPath(), "txc_merge_test_" + Guid.NewGuid().ToString("N")); + _from = Path.Combine(baseDir, "from"); + _into = Path.Combine(baseDir, "into"); + Directory.CreateDirectory(_from); + Directory.CreateDirectory(_into); + } + + public void Dispose() + { + var baseDir = Path.GetDirectoryName(_from); + if (baseDir is not null && Directory.Exists(baseDir)) + Directory.Delete(baseDir, recursive: true); + } + + private static void Write(string root, string relative, string content) + { + var path = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + [Fact] + public void Merge_NeverDeletesProjectFileOrBuildOutput() + { + Write(_into, "Solutions.Logic.csproj", ""); + Write(_into, "bin/Debug/whatever.dll", "binary"); + Write(_into, "obj/project.assets.json", "{}"); + Write(_into, "map.xml", ""); + Write(_into, "Other/Solution.xml", ""); + + Write(_from, "Other/Solution.xml", ""); + Write(_from, "Entities/account/Entity.xml", ""); + + SolutionSyncMerge.Merge(_from, _into); + + Assert.True(File.Exists(Path.Combine(_into, "Solutions.Logic.csproj"))); + Assert.True(File.Exists(Path.Combine(_into, "bin", "Debug", "whatever.dll"))); + Assert.True(File.Exists(Path.Combine(_into, "obj", "project.assets.json"))); + Assert.True(File.Exists(Path.Combine(_into, "map.xml"))); + Assert.Equal("", File.ReadAllText(Path.Combine(_into, "Other", "Solution.xml"))); + Assert.True(File.Exists(Path.Combine(_into, "Entities", "account", "Entity.xml"))); + } + + [Fact] + public void Merge_RemovesStaleFilesWithinComponentFolders() + { + Write(_into, "Entities/account/Entity.xml", ""); + Write(_into, "Entities/contact/Entity.xml", ""); + Write(_from, "Entities/account/Entity.xml", ""); + + var removed = SolutionSyncMerge.Merge(_from, _into); + + Assert.True(File.Exists(Path.Combine(_into, "Entities", "account", "Entity.xml"))); + Assert.False(Directory.Exists(Path.Combine(_into, "Entities", "contact"))); + Assert.Contains(removed, r => r.Replace('\\', '/') == "Entities/contact/Entity.xml"); + } + + [Fact] + public void Merge_LeavesUntouchedComponentFoldersAbsentFromExport() + { + Write(_into, "WebResources/udpp_main.js.data.xml", ""); + Write(_from, "Other/Solution.xml", ""); + + var removed = SolutionSyncMerge.Merge(_from, _into); + + Assert.True(File.Exists(Path.Combine(_into, "WebResources", "udpp_main.js.data.xml"))); + Assert.Empty(removed); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncPipelineTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncPipelineTests.cs new file mode 100644 index 00000000..b19acd69 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncPipelineTests.cs @@ -0,0 +1,77 @@ +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class SolutionSyncPipelineTests : IDisposable +{ + private readonly string _base; + + public SolutionSyncPipelineTests() + { + _base = Path.Combine(Path.GetTempPath(), "txc_pipeline_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_base); + } + + public void Dispose() + { + if (Directory.Exists(_base)) + Directory.Delete(_base, recursive: true); + } + + [Fact] + public void ReferencedPluginDllExcluded_NonReferencedKept() + { + var pluginProj = Path.Combine(_base, "Plugins.Warehouse", "Plugins.Warehouse.csproj"); + Directory.CreateDirectory(Path.GetDirectoryName(pluginProj)!); + File.WriteAllText(pluginProj, + """ + + + Plugin + PluginsWarehouse + + + """); + + var solDir = Path.Combine(_base, "Solutions.Logic"); + Directory.CreateDirectory(solDir); + var solProj = Path.Combine(solDir, "Solutions.Logic.csproj"); + File.WriteAllText(solProj, + ""); + + var staging = Path.Combine(_base, "staging"); + WriteServerAssembly(staging, "PluginsWarehouse-38E8D392-49D6", "PluginsWarehouse", "PluginsWarehouse, Version=1.0.12605.27000, Culture=neutral, PublicKeyToken=73895ec8fc11dc14"); + WriteServerAssembly(staging, "ThirdParty-AAAABBBB-CCCC", "ThirdParty", "ThirdParty, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); + + SolutionSyncTransform.NormalizePluginAssemblyPaths(staging); + var refs = ProjectReferenceReader.ReadPluginAssemblyNames(solProj); + var excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(staging, refs); + SolutionSyncMerge.Merge(staging, solDir); + + var pa = Path.Combine(solDir, "PluginAssemblies"); + + Assert.Contains("PluginsWarehouse", refs); + Assert.Contains("PluginsWarehouse.dll", excluded); + + // Referenced plugin: data.xml lands, binary does not. + Assert.True(File.Exists(Path.Combine(pa, "PluginsWarehouse.dll.data.xml"))); + Assert.False(File.Exists(Path.Combine(pa, "PluginsWarehouse.dll"))); + + // Non-referenced plugin: binary stays in the solution root. + Assert.True(File.Exists(Path.Combine(pa, "ThirdParty.dll.data.xml"))); + Assert.True(File.Exists(Path.Combine(pa, "ThirdParty.dll"))); + + // Project file is never touched. + Assert.True(File.Exists(solProj)); + } + + private static void WriteServerAssembly(string stagingRoot, string nestedFolder, string baseName, string fullName) + { + var dir = Path.Combine(stagingRoot, "PluginAssemblies", nestedFolder); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, baseName + ".dll"), "binary"); + File.WriteAllText(Path.Combine(dir, baseName + ".dll.data.xml"), + $"/PluginAssemblies/{nestedFolder}/{baseName}.dll"); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncTransformTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncTransformTests.cs new file mode 100644 index 00000000..0de1c394 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionSyncTransformTests.cs @@ -0,0 +1,171 @@ +using System.Xml.Linq; +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class SolutionSyncTransformTests : IDisposable +{ + private readonly string _root; + + public SolutionSyncTransformTests() + { + _root = Path.Combine(Path.GetTempPath(), "txc_sync_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + private string WriteAssembly(string folderName, string fileBaseName, string fullName, string fileNameElement) + { + var dir = Path.Combine(_root, "PluginAssemblies", folderName); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, fileBaseName + ".dll"), "binary"); + var dataXml = $""" + + + 0 + {fileNameElement} + + """; + File.WriteAllText(Path.Combine(dir, fileBaseName + ".dll.data.xml"), dataXml); + return dir; + } + + [Fact] + public void Normalize_FlattensFolderAndRewritesFileName() + { + WriteAssembly( + "MyPlugin-38E8D392-49D6-4DE7-9FF7-F1338E8DD6EE", + "MyPlugin", + "Acme.MyPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", + "/PluginAssemblies/MyPlugin-38E8D392-49D6-4DE7-9FF7-F1338E8DD6EE/MyPlugin.dll"); + + var normalized = SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + var pluginsDir = Path.Combine(_root, "PluginAssemblies"); + Assert.Equal(new[] { "MyPlugin.dll" }, normalized); + Assert.True(File.Exists(Path.Combine(pluginsDir, "MyPlugin.dll"))); + Assert.True(File.Exists(Path.Combine(pluginsDir, "MyPlugin.dll.data.xml"))); + Assert.Empty(Directory.GetDirectories(pluginsDir)); + + var doc = XDocument.Load(Path.Combine(pluginsDir, "MyPlugin.dll.data.xml")); + Assert.Equal("/PluginAssemblies/MyPlugin.dll", doc.Descendants("FileName").Single().Value); + } + + [Fact] + public void Normalize_IsIdempotent_WhenAlreadyFlat() + { + var pluginsDir = Path.Combine(_root, "PluginAssemblies"); + Directory.CreateDirectory(pluginsDir); + File.WriteAllText(Path.Combine(pluginsDir, "Flat.dll"), "binary"); + File.WriteAllText(Path.Combine(pluginsDir, "Flat.dll.data.xml"), "/PluginAssemblies/Flat.dll"); + + var normalized = SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + Assert.Empty(normalized); + Assert.True(File.Exists(Path.Combine(pluginsDir, "Flat.dll"))); + } + + [Fact] + public void Normalize_NoOp_WhenNoPluginAssembliesFolder() + { + var normalized = SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + Assert.Empty(normalized); + } + + [Fact] + public void Exclude_DeletesDll_KeepsDataXml_ForExactMatch() + { + WriteAssembly("X", "MyPlugin", "MyPlugin, Version=1.0.0.0", "/PluginAssemblies/MyPlugin.dll"); + SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + var excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(_root, new[] { "MyPlugin" }); + + var pluginsDir = Path.Combine(_root, "PluginAssemblies"); + Assert.Equal(new[] { "MyPlugin.dll" }, excluded); + Assert.False(File.Exists(Path.Combine(pluginsDir, "MyPlugin.dll"))); + Assert.True(File.Exists(Path.Combine(pluginsDir, "MyPlugin.dll.data.xml"))); + } + + [Fact] + public void Exclude_MatchesDottedNamespaceExtension() + { + WriteAssembly("X", "MoveOrder.Logic", "Acme.Apps.MoveOrder.Logic, Version=1.0.0.0", "/PluginAssemblies/MoveOrder.Logic.dll"); + SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + var excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(_root, new[] { "MoveOrder.Logic" }); + + Assert.Equal(new[] { "MoveOrder.Logic.dll" }, excluded); + } + + [Fact] + public void Exclude_KeepsBinary_WhenNotReferenced() + { + WriteAssembly("X", "ThirdParty", "ThirdParty, Version=1.0.0.0", "/PluginAssemblies/ThirdParty.dll"); + SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + var excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(_root, new[] { "MyPlugin" }); + + var pluginsDir = Path.Combine(_root, "PluginAssemblies"); + Assert.Empty(excluded); + Assert.True(File.Exists(Path.Combine(pluginsDir, "ThirdParty.dll"))); + } + + [Fact] + public void Exclude_NoOp_WhenNoReferences() + { + WriteAssembly("X", "MyPlugin", "MyPlugin, Version=1.0.0.0", "/PluginAssemblies/MyPlugin.dll"); + SolutionSyncTransform.NormalizePluginAssemblyPaths(_root); + + var excluded = SolutionSyncTransform.ExcludeProjectReferenceBinaries(_root, Array.Empty()); + + Assert.Empty(excluded); + Assert.True(File.Exists(Path.Combine(_root, "PluginAssemblies", "MyPlugin.dll"))); + } + + private void WriteWebResource(string name) + { + var dir = Path.Combine(_root, "WebResources"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, name), "content"); + File.WriteAllText(Path.Combine(dir, name + ".data.xml"), $"{name}"); + } + + [Fact] + public void ExcludeWebResource_DeletesContent_KeepsDataXml_WhenMatched() + { + WriteWebResource("udpp_main.js"); + var dir = Path.Combine(_root, "WebResources"); + + var excluded = SolutionSyncTransform.ExcludeScriptLibraryWebResources(_root, new[] { "udpp_main.js" }); + + Assert.Equal(new[] { "udpp_main.js" }, excluded); + Assert.False(File.Exists(Path.Combine(dir, "udpp_main.js"))); + Assert.True(File.Exists(Path.Combine(dir, "udpp_main.js.data.xml"))); + } + + [Fact] + public void ExcludeWebResource_KeepsContent_WhenNotMatched() + { + WriteWebResource("udpp_static.svg"); + var dir = Path.Combine(_root, "WebResources"); + + var excluded = SolutionSyncTransform.ExcludeScriptLibraryWebResources(_root, new[] { "udpp_main.js" }); + + Assert.Empty(excluded); + Assert.True(File.Exists(Path.Combine(dir, "udpp_static.svg"))); + Assert.True(File.Exists(Path.Combine(dir, "udpp_static.svg.data.xml"))); + } + + [Fact] + public void ExcludeWebResource_NoOp_WhenNoWebResourcesFolder() + { + var excluded = SolutionSyncTransform.ExcludeScriptLibraryWebResources(_root, new[] { "udpp_main.js" }); + Assert.Empty(excluded); + } +}