Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"cSpell.words": [
"Dataverse",
"msapp",
"msapr",
"PPUX",
"RGBA"
],
Expand Down
25 changes: 22 additions & 3 deletions src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<MsappPackException>()
.WithMessage($"*'{outputMsappPath}'*");
}
Expand All @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/Persistence/MsappPacking/MsappPackOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking;

/// <summary>
/// Options for <see cref="MsappPackingService.PackFromMsappReferenceFileAsync"/>.
/// </summary>
public sealed record MsappPackOptions
{
/// <summary>
/// Indicates whether to allow overwriting the output .msapp file if it already exists.
/// </summary>
public bool OverwriteOutput { get; init; }

/// <summary>
/// When true, instructs the Power Apps runtime to load from the unpacked YAML source files.
/// Only valid when <see cref="MsappUnpackableContentType.PaYamlSourceCode"/> was unpacked.
/// </summary>
public bool EnableLoadFromYaml { get; init; }
}
39 changes: 19 additions & 20 deletions src/Persistence/MsappPacking/MsappPackingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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.");
Expand All @@ -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),
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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.
/// </summary>
/// <param name="packingClient">Information about the client performing the packing.</param>
/// <param name="overwriteOutput">Indicates whether to allow overwriting the output if it already exists.</param>
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)!;
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -287,7 +286,7 @@ await outputMsapp.AddEntryFromJsonAsync(
PackingClient = packingClient,
LoadConfiguration = new()
{
LoadFromYaml = enableLoadFromYaml,
LoadFromYaml = options.EnableLoadFromYaml,
},
},
MsappSerialization.PackedJsonSerializeOptions,
Expand Down
27 changes: 27 additions & 0 deletions src/Persistence/MsappPacking/MsappUnpackOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking;

/// <summary>
/// Options for <see cref="MsappPackingService.UnpackToDirectoryAsync"/>.
/// </summary>
public sealed record MsappUnpackOptions
{
/// <summary>
/// Indicates whether to allow overwriting output files/folders if they already exist.
/// </summary>
public bool OverwriteOutput { get; init; }

/// <summary>
/// Configuration describing which content types to unpack.
/// If null, the default <see cref="UnpackedConfiguration"/> is used.
/// </summary>
public UnpackedConfiguration UnpackedConfig { get; init; } = new();

/// <summary>
/// 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.
/// </summary>
public string? MsaprName { get; init; }
}
Loading