diff --git a/.vscode/settings.json b/.vscode/settings.json index d5e757dc..72b8519b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "Dataverse", "msapp", + "msapr", "PPUX", "RGBA" ], diff --git a/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs b/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs index 900f2bd8..5e545969 100644 --- a/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs +++ b/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs @@ -101,6 +101,25 @@ public async Task UnpackToDirectoryWithDefaultConfig() ; } + [TestMethod] + public async Task UnpackToDirectoryWithMsaprName() + { + // Arrange + var testDir = CreateTestOutputFolder(ensureEmpty: true); + var unpackedDir = Path.Combine(testDir, "unpackedOut"); + var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible); + var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default); + + // Act: unpack with default config (only PaYamlSourceCode is unpacked to disk) + await service.UnpackToDirectoryAsync(msappPath, unpackedDir, new() { MsaprName = "customMsaprName" }); + + Directory.Exists(unpackedDir).Should().BeTrue("service should have created the output folder if it didn't already exist"); + + // Assert: .msapr is created alongside the extracted source + Directory.GetFiles(unpackedDir, "*.msapr").Should().ContainSingle() + .Which.Should().Be(Path.Combine(unpackedDir, "customMsaprName.msapr"), "the .msapr file should be created with the custom name specified in options"); + } + [TestMethod] [DataRow("Header-DocV-1.250.json")] // MSAppStructureVersion is absent (normalizes to 1.0) [DataRow("Header-DocV-1.285.json")] // MSAppStructureVersion 2.0 @@ -143,7 +162,7 @@ public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries(bool await service.UnpackToDirectoryAsync(msappPath, unpackedDir); var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName); await service.PackFromMsappReferenceFileAsync(msaprPath, repackedMsappPath, TestPackingClient - , enableLoadFromYaml: enableLoadFromYaml); + , new() { EnableLoadFromYaml = enableLoadFromYaml }); // Assert: output file exists File.Exists(repackedMsappPath).Should().BeTrue("the repacked .msapp file should be created"); @@ -190,7 +209,7 @@ public async Task PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwrite File.WriteAllText(outputMsappPath, "existing content"); // Act & Assert - await FluentActions.Invoking(() => service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: false)) + await FluentActions.Invoking(() => service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, new() { OverwriteOutput = false })) .Should().ThrowAsync() .WithMessage($"*'{outputMsappPath}'*"); } @@ -212,7 +231,7 @@ public async Task PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue() File.WriteAllText(outputMsappPath, "existing content"); // Act: should not throw - await service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: true); + await service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, new() { OverwriteOutput = true }); // Assert: the file was overwritten with a valid msapp using var msapp = MsappArchiveFactory.Default.Open(outputMsappPath); diff --git a/src/Persistence/MsappPacking/MsappPackOptions.cs b/src/Persistence/MsappPacking/MsappPackOptions.cs new file mode 100644 index 00000000..32a878f0 --- /dev/null +++ b/src/Persistence/MsappPacking/MsappPackOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking; + +/// +/// Options for . +/// +public sealed record MsappPackOptions +{ + /// + /// Indicates whether to allow overwriting the output .msapp file if it already exists. + /// + public bool OverwriteOutput { get; init; } + + /// + /// When true, instructs the Power Apps runtime to load from the unpacked YAML source files. + /// Only valid when was unpacked. + /// + public bool EnableLoadFromYaml { get; init; } +} diff --git a/src/Persistence/MsappPacking/MsappPackingService.cs b/src/Persistence/MsappPacking/MsappPackingService.cs index 50128162..7e8922db 100644 --- a/src/Persistence/MsappPacking/MsappPackingService.cs +++ b/src/Persistence/MsappPacking/MsappPackingService.cs @@ -37,15 +37,15 @@ public sealed class MsappPackingService( public async Task UnpackToDirectoryAsync( string msappPath, string outputDirectory, - bool overwriteOutput = false, - UnpackedConfiguration? unpackedConfig = null, + MsappUnpackOptions? options = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(msappPath); ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); - unpackedConfig ??= new(); - if (!unpackedConfig.ContentTypes.Any()) - throw new ArgumentException($"{nameof(unpackedConfig)}.{nameof(unpackedConfig.ContentTypes)} should not be empty", nameof(unpackedConfig)); + options ??= new(); + + if (!options.UnpackedConfig.ContentTypes.Any()) + throw new ArgumentException($"{nameof(options)}.{nameof(options.UnpackedConfig)}.{nameof(options.UnpackedConfig.ContentTypes)} should not be empty", nameof(options)); if (outputDirectory != Path.GetFullPath(outputDirectory)) throw new ArgumentException($"{nameof(outputDirectory)} should be an absolute path.", nameof(outputDirectory)); @@ -55,13 +55,13 @@ public async Task UnpackToDirectoryAsync( outputDirectoryWithTrailingSlash += Path.DirectorySeparatorChar; // Step 1: compute output paths - var msaprPath = Path.Combine(outputDirectory, Path.GetFileNameWithoutExtension(msappPath) + MsaprLayoutConstants.FileExtensions.Msapr); + var msaprPath = Path.Combine(outputDirectory, (options.MsaprName ?? Path.GetFileNameWithoutExtension(msappPath)) + MsaprLayoutConstants.FileExtensions.Msapr); var assetsOutputDirectoryPath = Path.Combine(outputDirectory, MsappLayoutConstants.DirectoryNames.Assets); var srcOutputDirectoryPath = Path.Combine(outputDirectory, MsappLayoutConstants.DirectoryNames.Src); // Step 2: check for conflicts with existing files/folders // Note: This logic doesn't require inspecting the msapp first, as the top-level output files/folders are replaced wholesale - if (!overwriteOutput) + if (!options.OverwriteOutput) { if (File.Exists(msaprPath)) throw new MsappUnpackException($"Output file '{msaprPath}' already exists and overwriting output is not enabled."); @@ -88,7 +88,7 @@ public async Task UnpackToDirectoryAsync( ValidateMsappUnpackIsSupported(sourceArchive); - var entryInstructions = BuildUnpackInstructions(sourceArchive, unpackedConfig); + var entryInstructions = BuildUnpackInstructions(sourceArchive, options.UnpackedConfig); _logger?.LogDebug( "Entry types: {SourceCode} source-code, {Asset} asset, {Header} header, {Other} other entries.", entryInstructions.Count(e => e.ContentType == MsappContentType.PaYamlSourceCode), @@ -104,7 +104,7 @@ public async Task UnpackToDirectoryAsync( // Create/overwite .msapr Directory.CreateDirectory(outputDirectory); - using var msaprArchive = await _msappReferenceFactory.CreateNewAsync(msaprPath, CreateMsaprHeaderJson(unpackedConfig), overwrite: overwriteOutput, cancellationToken).ConfigureAwait(false); + using var msaprArchive = await _msappReferenceFactory.CreateNewAsync(msaprPath, CreateMsaprHeaderJson(options.UnpackedConfig), overwrite: options.OverwriteOutput, cancellationToken).ConfigureAwait(false); // Perform unpack instructions on msapp entries var extractedCount = 0; @@ -113,7 +113,7 @@ public async Task UnpackToDirectoryAsync( { if (entryInstruction.InstructionType is MsappUnpackInstructionType.UnpackToRelativeDirectory) { - await entryInstruction.MsappEntry.ExtractRelativeToDirectoryAsync(outputDirectory, overwrite: overwriteOutput, cancellationToken).ConfigureAwait(false); + await entryInstruction.MsappEntry.ExtractRelativeToDirectoryAsync(outputDirectory, overwrite: options.OverwriteOutput, cancellationToken).ConfigureAwait(false); extractedCount++; } else if (entryInstruction.InstructionType is MsappUnpackInstructionType.CopyToMsapr) @@ -221,21 +221,20 @@ private static MsappContentType GetContentType(PaArchivePath entryPath) /// The contents of the folder where the msapr file resides are inspected to be included in the msapp. /// /// Information about the client performing the packing. - /// Indicates whether to allow overwriting the output if it already exists. public async Task PackFromMsappReferenceFileAsync( string msaprPath, string outputMsappPath, - PackedJsonPackingClient? packingClient = null, - bool overwriteOutput = false, - bool enableLoadFromYaml = false, + PackedJsonPackingClient packingClient, + MsappPackOptions? options = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(msaprPath); ArgumentException.ThrowIfNullOrWhiteSpace(outputMsappPath); + options ??= new(); msaprPath = Path.GetFullPath(msaprPath); - if (!overwriteOutput && File.Exists(outputMsappPath)) + if (!options.OverwriteOutput && File.Exists(outputMsappPath)) throw new MsappPackException($"Output file '{outputMsappPath}' already exists and overwriting output is not enabled."); var unpackedFolderPath = Path.GetDirectoryName(msaprPath)!; @@ -246,7 +245,7 @@ public async Task PackFromMsappReferenceFileAsync( // Materialize instructions before creating the output file so any errors (e.g. unsupported src files) are raised first. var packInstructions = BuildPackInstructions(msaprArchive, unpackedFolderPath, unpackedConfig, _logger).ToList(); - using var outputMsapp = _msappFactory.Create(outputMsappPath, overwrite: overwriteOutput); + using var outputMsapp = _msappFactory.Create(outputMsappPath, overwrite: options.OverwriteOutput); var packedJsonPath = new PaArchivePath(MsappLayoutConstants.FileNames.Packed); var copiedFromMsaprCount = 0; @@ -272,10 +271,10 @@ public async Task PackFromMsappReferenceFileAsync( } } - if (enableLoadFromYaml && !unpackedConfig.EnablesContentType(MsappUnpackableContentType.PaYamlSourceCode)) + if (options.EnableLoadFromYaml && !unpackedConfig.EnablesContentType(MsappUnpackableContentType.PaYamlSourceCode)) { - _logger?.LogWarning("enableLoadFromYaml is set to true, but the unpacked configuration does not indicate that PaYamlSourceCode was unpacked. Ignoring request to load from yaml."); - enableLoadFromYaml = false; + _logger?.LogWarning("EnableLoadFromYaml is set to true, but the unpacked configuration does not indicate that PaYamlSourceCode was unpacked. Ignoring request to load from yaml."); + options = options with { EnableLoadFromYaml = false }; } await outputMsapp.AddEntryFromJsonAsync( @@ -287,7 +286,7 @@ await outputMsapp.AddEntryFromJsonAsync( PackingClient = packingClient, LoadConfiguration = new() { - LoadFromYaml = enableLoadFromYaml, + LoadFromYaml = options.EnableLoadFromYaml, }, }, MsappSerialization.PackedJsonSerializeOptions, diff --git a/src/Persistence/MsappPacking/MsappUnpackOptions.cs b/src/Persistence/MsappPacking/MsappUnpackOptions.cs new file mode 100644 index 00000000..5473a607 --- /dev/null +++ b/src/Persistence/MsappPacking/MsappUnpackOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking; + +/// +/// Options for . +/// +public sealed record MsappUnpackOptions +{ + /// + /// Indicates whether to allow overwriting output files/folders if they already exist. + /// + public bool OverwriteOutput { get; init; } + + /// + /// Configuration describing which content types to unpack. + /// If null, the default is used. + /// + public UnpackedConfiguration UnpackedConfig { get; init; } = new(); + + /// + /// The name (without extension) to use for the output .msapr file. + /// If null, defaults to the file name without extension of the source .msapp file. + /// + public string? MsaprName { get; init; } +}