diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8a760968..a35eaf30 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -46,6 +46,9 @@ jobs: - name: Pack - Persistence run: dotnet pack --no-build -c ${{ env.Configuration }} src/Persistence/Microsoft.PowerPlatform.PowerApps.Persistence.csproj + - name: Pack - Persistence.Testing + run: dotnet pack --no-build -c ${{ env.Configuration }} src/Persistence.Testing/Microsoft.PowerPlatform.PowerApps.Persistence.Testing.csproj + # Run tests - name: Test - PASopa.sln run: dotnet test --no-build --solution src/PASopa.sln diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1423677f..918cbe10 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,11 +5,17 @@ + - + + diff --git a/src/PASopa.sln b/src/PASopa.sln index ddc27f89..35c2a2f9 100644 --- a/src/PASopa.sln +++ b/src/PASopa.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerPlatform.PowerApps.Persistence", "Persistence\Microsoft.PowerPlatform.PowerApps.Persistence.csproj", "{906B4EA5-F287-4D0E-A511-0D89B2DD9C14}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerPlatform.PowerApps.Persistence.Testing", "Persistence.Testing\Microsoft.PowerPlatform.PowerApps.Persistence.Testing.csproj", "{D4E5F6A1-B2C3-4D5E-8F90-1A2B3C4D5E6F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Persistence.Tests", "Persistence.Tests\Persistence.Tests.csproj", "{8AB1C901-FE5E-44BF-AA21-B8F20A9D7CDD}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7361DB16-D534-4E0E-8597-BE22317DEF47}" @@ -52,6 +54,10 @@ Global {906B4EA5-F287-4D0E-A511-0D89B2DD9C14}.Debug|Any CPU.Build.0 = Debug|Any CPU {906B4EA5-F287-4D0E-A511-0D89B2DD9C14}.Release|Any CPU.ActiveCfg = Release|Any CPU {906B4EA5-F287-4D0E-A511-0D89B2DD9C14}.Release|Any CPU.Build.0 = Release|Any CPU + {D4E5F6A1-B2C3-4D5E-8F90-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4E5F6A1-B2C3-4D5E-8F90-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4E5F6A1-B2C3-4D5E-8F90-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4E5F6A1-B2C3-4D5E-8F90-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU {8AB1C901-FE5E-44BF-AA21-B8F20A9D7CDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8AB1C901-FE5E-44BF-AA21-B8F20A9D7CDD}.Debug|Any CPU.Build.0 = Debug|Any CPU {8AB1C901-FE5E-44BF-AA21-B8F20A9D7CDD}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/Persistence.Tests/CapturingLogger.cs b/src/Persistence.Testing/CapturingLogger.cs similarity index 86% rename from src/Persistence.Tests/CapturingLogger.cs rename to src/Persistence.Testing/CapturingLogger.cs index 5e54514a..b2e816b8 100644 --- a/src/Persistence.Tests/CapturingLogger.cs +++ b/src/Persistence.Testing/CapturingLogger.cs @@ -3,12 +3,12 @@ using Microsoft.Extensions.Logging; -namespace Persistence.Tests; +namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing; /// /// A logger implementation which captures log entries so they can be inspected in tests. /// -internal sealed class CapturingLogger : ILogger +public sealed class CapturingLogger : ILogger { public record LogEntry(LogLevel Level, string Message); diff --git a/src/Persistence.Tests/Extensions/PaArchiveAssertionExtensions.cs b/src/Persistence.Testing/Extensions/PaArchiveAssertionExtensions.cs similarity index 69% rename from src/Persistence.Tests/Extensions/PaArchiveAssertionExtensions.cs rename to src/Persistence.Testing/Extensions/PaArchiveAssertionExtensions.cs index 34023181..be70903e 100644 --- a/src/Persistence.Tests/Extensions/PaArchiveAssertionExtensions.cs +++ b/src/Persistence.Testing/Extensions/PaArchiveAssertionExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using FluentAssertions; using FluentAssertions.Execution; using FluentAssertions.Primitives; using FluentAssertions.Specialized; @@ -8,7 +9,7 @@ using Microsoft.PowerPlatform.PowerApps.Persistence.Compression; using Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Models; -namespace Persistence.Tests.Extensions; +namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing.Extensions; [DebuggerNonUserCode] public static class PaArchiveAssertionExtensions @@ -23,25 +24,25 @@ public static PaArchiveAssertions Should(this PaArchive? value) public class PaArchiveAssertions(PaArchive? value) : ObjectAssertions(value) { - public AndWhichConstraint ContainEntry(string fullPath, string? because = null, params object[] becauseArgs) + public AndWhichConstraint HaveEntry(string fullPath, string? because = null, params object[] becauseArgs) { Execute.Assertion .ForCondition(Subject != null && Subject.ContainsEntry(fullPath)) .BecauseOf(because, becauseArgs) .FailWith( - "Expected {context:archive} to contain entry with path {0}{reason}, but was not found.", + "Expected {context:archive} to have an entry with path {0}{reason}, but was not found.", fullPath); return new(this, Subject?.GetEntryOrDefault(fullPath)!); } - public AndConstraint NotContainEntry(string fullPath, string? because = null, params object[] becauseArgs) + public AndConstraint NotHaveEntry(string fullPath, string? because = null, params object[] becauseArgs) { Execute.Assertion .ForCondition(Subject != null && !Subject.ContainsEntry(fullPath)) .BecauseOf(because, becauseArgs) .FailWith( - "Expected {context:archive} to NOT contain entry with path {0}{reason}, but it was found.", + "Expected {context:archive} to NOT have an entry with path {0}{reason}, but it was found.", fullPath); return new(this); @@ -64,7 +65,7 @@ public AndConstraint HaveCountEntriesInDirectory(string dir .ForCondition(actualCount == expectedCount) .BecauseOf(because, becauseArgs) .FailWith( - "Expected {context:archive} to contain {0} entries in directory {1}{2}{reason}, but {3} were found.", + "Expected {context:archive} to have {0} entries in directory {1}{2}{reason}, but {3} were found.", expectedCount, directoryPath, recursive ? " recursive" : string.Empty, @@ -72,24 +73,24 @@ public AndConstraint HaveCountEntriesInDirectory(string dir return new(this); } - public AndConstraint NotContainAnyEntriesInDirectory(string directoryPath, string? because = null, params object[] becauseArgs) + public AndConstraint NotHaveAnyEntriesInDirectory(string directoryPath, string? because = null, params object[] becauseArgs) { - return NotContainAnyEntriesInDirectory(directoryPath, recursive: false, because, becauseArgs); + return NotHaveAnyEntriesInDirectory(directoryPath, recursive: false, because, becauseArgs); } - public AndConstraint NotContainAnyEntriesInDirectoryRecursive(string directoryPath, string? because = null, params object[] becauseArgs) + public AndConstraint NotHaveAnyEntriesInDirectoryRecursive(string directoryPath, string? because = null, params object[] becauseArgs) { - return NotContainAnyEntriesInDirectory(directoryPath, recursive: true, because, becauseArgs); + return NotHaveAnyEntriesInDirectory(directoryPath, recursive: true, because, becauseArgs); } - public AndConstraint NotContainAnyEntriesInDirectory(string directoryPath, bool recursive, string? because = null, params object[] becauseArgs) + public AndConstraint NotHaveAnyEntriesInDirectory(string directoryPath, bool recursive, string? because = null, params object[] becauseArgs) { var actualCount = Subject?.GetEntriesInDirectory(directoryPath, recursive: recursive).Count(); Execute.Assertion .ForCondition(actualCount == 0) .BecauseOf(because, becauseArgs) .FailWith( - "Expected {context:archive} to NOT contain any entries in directory {0}{1}{reason}, but {2} were found.", + "Expected {context:archive} to NOT have any entries in directory {0}{1}{reason}, but {2} were found.", directoryPath, recursive ? " recursive" : string.Empty, actualCount); diff --git a/src/Persistence.Tests/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs b/src/Persistence.Testing/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs similarity index 97% rename from src/Persistence.Tests/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs rename to src/Persistence.Testing/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs index c7bec3d1..5bff94cf 100644 --- a/src/Persistence.Tests/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs +++ b/src/Persistence.Testing/Extensions/PersistenceLibraryExceptionAssertionsExtensions.cs @@ -6,7 +6,7 @@ using FluentAssertions.Specialized; using Microsoft.PowerPlatform.PowerApps.Persistence; -namespace Persistence.Tests.Extensions; +namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing.Extensions; public static class PersistenceLibraryExceptionAssertionsExtensions { diff --git a/src/Persistence.Tests/FilePathComparer.cs b/src/Persistence.Testing/FilePathComparer.cs similarity index 77% rename from src/Persistence.Tests/FilePathComparer.cs rename to src/Persistence.Testing/FilePathComparer.cs index b6ef9811..fe57fe3e 100644 --- a/src/Persistence.Tests/FilePathComparer.cs +++ b/src/Persistence.Testing/FilePathComparer.cs @@ -1,8 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Persistence.Tests; +namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing; +/// +/// An implementation of that compares strings assuming they're file paths +/// in a way that is consistent with how Windows file paths are ordered. +/// Namely, so directories come before files in the same parent directory, and path separators are treated as significant but not as characters to sort on, and comparison is case-insensitive. +/// public class FilePathComparer : IComparer { public static readonly FilePathComparer Instance = new(); diff --git a/src/Persistence.Testing/Microsoft.PowerPlatform.PowerApps.Persistence.Testing.csproj b/src/Persistence.Testing/Microsoft.PowerPlatform.PowerApps.Persistence.Testing.csproj new file mode 100644 index 00000000..48ff7989 --- /dev/null +++ b/src/Persistence.Testing/Microsoft.PowerPlatform.PowerApps.Persistence.Testing.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + true + true + + + + Microsoft Power Platform Canvas App Persistence Testing Library + Preview Release + + Notice: + This package is a preview release - use at your own risk. + + We have not stabilized on Namespace or Class names with this package as of yet and things will + change as we move through the preview. + + See https://github.com/microsoft/PowerApps-Tooling/releases for the latest release notes. + + + + + + + + + + + + + diff --git a/src/Persistence.Tests/Compression/PaArchivePathTests.cs b/src/Persistence.Tests/Compression/PaArchivePathTests.cs index 1a1546de..ec23ab77 100644 --- a/src/Persistence.Tests/Compression/PaArchivePathTests.cs +++ b/src/Persistence.Tests/Compression/PaArchivePathTests.cs @@ -50,6 +50,41 @@ public void ValidPathTest(string fullPath, string expectedRelativePathWindows, s archivePath.IsDirectory.Should().Be(expectIsDirectory); } + // Chars which are valid in OS paths and allowed here: + [TestMethod] + [DataRow('~')] + [DataRow('!')] + [DataRow('@')] + [DataRow('#')] + [DataRow('$')] + [DataRow('%')] + [DataRow('^')] + [DataRow('&')] + [DataRow(';')] + [DataRow('+')] + [DataRow('=')] + [DataRow('`')] + [DataRow('\'')] + // Latin extended: + [DataRow('é')] // LATIN SMALL LETTER E WITH ACUTE + [DataRow('ñ')] // LATIN SMALL LETTER N WITH TILDE + [DataRow('ü')] // LATIN SMALL LETTER U WITH DIAERESIS + // Japanese: + [DataRow('あ')] // HIRAGANA LETTER A + [DataRow('ア')] // KATAKANA LETTER A + // CJK: + [DataRow('中')] // CJK UNIFIED IDEOGRAPH-4E2D + [DataRow('文')] // CJK UNIFIED IDEOGRAPH-6587 + public void ValidPathWithSpecialCharsTest(char specialChar) + { + var fullPath = $"dir{Path.DirectorySeparatorChar}{specialChar}"; + + var archivePath = new PaArchivePath(fullPath); + archivePath.FullName.Should().Be(fullPath); + archivePath.Name.Should().Be(specialChar.ToString()); + archivePath.IsDirectory.Should().BeFalse(); + } + [TestMethod] // Single directory: [DataRow(@"dir1", @"dir1\", "dir1", true)] @@ -112,16 +147,8 @@ public void ValidDirectoryPathTest(string fullPath, string expectedRelativePathW [DataRow(@"foo|>bar", PaArchivePathInvalidReason.InvalidPathChars)] [DataRow(@"foo?bar", PaArchivePathInvalidReason.InvalidPathChars)] [DataRow(@"foo*bar", PaArchivePathInvalidReason.InvalidPathChars)] - // Url-special chars - [DataRow(@"foo#bar", PaArchivePathInvalidReason.InvalidPathChars)] - [DataRow(@"foo%bar", PaArchivePathInvalidReason.InvalidPathChars)] - // Quotes - [DataRow(@"foo'bar", PaArchivePathInvalidReason.InvalidPathChars)] - [DataRow(@"foo`bar", PaArchivePathInvalidReason.InvalidPathChars)] + // Quotes (double-quote is still invalid on Windows) [DataRow(@"foo""bar", PaArchivePathInvalidReason.InvalidPathChars)] - // Other chars which have meanings in some languages - [DataRow(@"foo!bar", PaArchivePathInvalidReason.InvalidPathChars)] - [DataRow(@"foo~bar", PaArchivePathInvalidReason.InvalidPathChars)] // Whitespace in segments: [DataRow(@" ", PaArchivePathInvalidReason.WhitespaceOnlySegment)] [DataRow(@" \dir1\", PaArchivePathInvalidReason.WhitespaceOnlySegment)] diff --git a/src/Persistence.Tests/Compression/PaArchiveTests.cs b/src/Persistence.Tests/Compression/PaArchiveTests.cs index 878bed9c..3d5267da 100644 --- a/src/Persistence.Tests/Compression/PaArchiveTests.cs +++ b/src/Persistence.Tests/Compression/PaArchiveTests.cs @@ -306,8 +306,8 @@ public void DirectoryEntriesIgnoredTest() using var paArchive = new PaArchive(stream, ZipArchiveMode.Read, logger: capturingLogger); // Assert: directory entries are ignored regardless of whether they have data - paArchive.Should().ContainEntry("Header.json"); - paArchive.Should().ContainEntry("Assets/img1.jpg"); + paArchive.Should().HaveEntry("Header.json"); + paArchive.Should().HaveEntry("Assets/img1.jpg"); paArchive.Entries.Should().HaveCount(2, "only file entries should be loaded"); // directory entries should produce a Warning that they are ignored @@ -340,7 +340,7 @@ public void DirectoryEntryWithDataProducesWarningButStillIgnoredTest() using var paArchive = new PaArchive(stream, ZipArchiveMode.Read, logger: capturingLogger); // Assert: directory entries are ignored regardless of whether they have data - paArchive.Should().ContainEntry("Header.json"); + paArchive.Should().HaveEntry("Header.json"); paArchive.Entries.Should().HaveCount(1, "only file entries should be loaded"); // directory entries should produce a Warning that they are ignored diff --git a/src/Persistence.Tests/Extensions/PersistenceFluentExtensions.cs b/src/Persistence.Tests/Extensions/PersistenceFluentExtensions.cs index e26e733d..29df2776 100644 --- a/src/Persistence.Tests/Extensions/PersistenceFluentExtensions.cs +++ b/src/Persistence.Tests/Extensions/PersistenceFluentExtensions.cs @@ -14,18 +14,18 @@ namespace Persistence.Tests.Extensions; /// /// Extensions for tests. /// -internal static class PersistenceFluentExtensions +internal static partial class PersistenceFluentExtensions { /// /// Asserts that the value is not null and gives the nullable assertion. /// Will result in a FluentAssertionException if the value is null. /// - public static void ShouldNotBeNull([NotNull] this T? value) + public static void ShouldNotBeNull([NotNull] this T? value, string because = "", params object[] becauseArgs) where T : notnull { if (value is null) { - value.Should().NotBeNull(); + value.Should().NotBeNull(because, becauseArgs); throw new InvalidOperationException("Should().NotBeNull() should have thrown."); } } @@ -62,7 +62,7 @@ public static AndConstraint BeYamlEquivalentTo(this St _ = assertions ?? throw new ArgumentNullException(nameof(assertions)); _ = expectedYaml ?? throw new ArgumentNullException(nameof(expectedYaml)); - assertions.Subject.ShouldNotBeNull(); + assertions.Subject.ShouldNotBeNull(because, becauseArgs); var actualYamlStream = new YamlStream(); using (var actualTextReader = new StringReader(assertions.Subject)) @@ -105,11 +105,9 @@ private static void CompareYamlTree(YamlDocument actualDoc, YamlDocument expecte { docContext ??= nameof(actualDoc); - using (var scope = new AssertionScope(docContext)) - { - var nodePath = string.Empty; // document root - CompareYamlTree(actualDoc.RootNode, expectedDoc.RootNode, nodePath); - } + using var scope = new AssertionScope(docContext); + var nodePath = string.Empty; // document root + CompareYamlTree(actualDoc.RootNode, expectedDoc.RootNode, nodePath); } private static void CompareYamlTree(YamlNode actualNode, YamlNode expectedNode, string nodePath) @@ -189,7 +187,8 @@ private static void CompareYamlTree(YamlNode actualNode, YamlNode expectedNode, } } - private static readonly Regex RequiresEscapingRegex = new(@"[^a-zA-Z0-1_-]", RegexOptions.Compiled); + [GeneratedRegex(@"[^a-zA-Z0-1_-]", RegexOptions.Compiled)] + private static partial Regex RequiresEscapingRegex(); private static string ToBreadcrumbPathSegment(this YamlScalarNode node) { @@ -198,7 +197,7 @@ private static string ToBreadcrumbPathSegment(this YamlScalarNode node) return "[~]"; } - if (RequiresEscapingRegex.IsMatch(node.Value)) + if (RequiresEscapingRegex().IsMatch(node.Value)) { return $"[\"{node.Value}\"]"; } diff --git a/src/Persistence.Tests/MsApp/MsappArchiveTests.cs b/src/Persistence.Tests/MsApp/MsappArchiveTests.cs index ad801353..a1d109f9 100644 --- a/src/Persistence.Tests/MsApp/MsappArchiveTests.cs +++ b/src/Persistence.Tests/MsApp/MsappArchiveTests.cs @@ -232,7 +232,12 @@ public void IsSafeForEntryPathSegmentTests() MsappArchive.IsSafeForEntryPathSegment("Foo\\Bar.pa.yaml").Should().BeFalse("separator chars should not be used for path segments"); MsappArchive.IsSafeForEntryPathSegment("\\Foo.pa.yaml").Should().BeFalse("separator chars should not be used for path segments"); - MsappArchive.IsSafeForEntryPathSegment("Foo/Bar.pa.yaml").Should().BeFalse("separator chars should not be used for path segments"); + MsappArchive.IsSafeForEntryPathSegment("Foo/\t.pa.yaml").Should().BeFalse("control chars should not be allowed"); + + // Currently, chars outside of ascii range are not allowed + MsappArchive.IsSafeForEntryPathSegment("Foo/éñü.pa.yaml").Should().BeFalse("latin chars are currently not allowed"); + MsappArchive.IsSafeForEntryPathSegment("Foo/あア.pa.yaml").Should().BeFalse("Japanese chars are currently not allowed"); + MsappArchive.IsSafeForEntryPathSegment("Foo/中文.pa.yaml").Should().BeFalse("CJK chars are currently not allowed"); } [TestMethod] diff --git a/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs b/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs index e90aad50..57ce010c 100644 --- a/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs +++ b/src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs @@ -58,45 +58,45 @@ public async Task UnpackToDirectoryWithDefaultConfig() "Assets should remain in the .msapr, not be extracted to disk with default config"); using var msaprArchive = MsappReferenceArchiveFactory.Default.Open(msaprPath); - msaprArchive.Should().ContainEntry(MsaprLayoutConstants.FileNames.MsaprHeader, "msapr header file should always be written"); + msaprArchive.Should().HaveEntry(MsaprLayoutConstants.FileNames.MsaprHeader, "msapr header file should always be written"); // Assert: .msapr contains the files from the msapp that were not extracted to disk. msaprArchive.Should() - .ContainEntry("msapp/Header.json") - .And.ContainEntry("msapp/Properties.json") - .And.ContainEntry("msapp/ComponentsMetadata.json") - .And.ContainEntry("msapp/AppCheckerResult.sarif") + .HaveEntry("msapp/Header.json") + .And.HaveEntry("msapp/Properties.json") + .And.HaveEntry("msapp/ComponentsMetadata.json") + .And.HaveEntry("msapp/AppCheckerResult.sarif") // editor state internal representation of controls and components - .And.ContainEntry("msapp/Components/7.json") - .And.ContainEntry("msapp/Controls/1.json") - .And.ContainEntry("msapp/Controls/4.json") + .And.HaveEntry("msapp/Components/7.json") + .And.HaveEntry("msapp/Controls/1.json") + .And.HaveEntry("msapp/Controls/4.json") // Hidden entities - .And.ContainEntry("msapp/References/Themes.json") - .And.ContainEntry("msapp/References/DataSources.json") - .And.ContainEntry("msapp/References/ModernThemes.json") - .And.ContainEntry("msapp/References/QualifiedValues.json") - .And.ContainEntry("msapp/References/Resources.json") - .And.ContainEntry("msapp/References/Templates.json") + .And.HaveEntry("msapp/References/Themes.json") + .And.HaveEntry("msapp/References/DataSources.json") + .And.HaveEntry("msapp/References/ModernThemes.json") + .And.HaveEntry("msapp/References/QualifiedValues.json") + .And.HaveEntry("msapp/References/Resources.json") + .And.HaveEntry("msapp/References/Templates.json") // Assets are not extracted with default config, so they should be in the .msapr - .And.ContainEntry("msapp/Assets/Images/e3a466bb-b793-4b1e-95a9-6b69efcd7b7b.jpg") - .And.ContainEntry("msapp/Assets/Images/d60d1b08-a1f6-46e7-b305-9c4b2d4d417c.png") - .And.ContainEntry("msapp/Assets/Images/fae39ff3-1276-4ea4-96d3-60ebee45b286.png") + .And.HaveEntry("msapp/Assets/Images/e3a466bb-b793-4b1e-95a9-6b69efcd7b7b.jpg") + .And.HaveEntry("msapp/Assets/Images/d60d1b08-a1f6-46e7-b305-9c4b2d4d417c.png") + .And.HaveEntry("msapp/Assets/Images/fae39ff3-1276-4ea4-96d3-60ebee45b286.png") ; // Assert: pa.yaml files were NOT copied into the .msapr (they live on disk) - msaprArchive.Should().NotContainAnyEntriesInDirectoryRecursive("msapp/Src"); + msaprArchive.Should().NotHaveAnyEntriesInDirectoryRecursive("msapp/Src"); // Assert other directories are empty msaprArchive.Should() .HaveCountEntriesInDirectory("", expectedCount: 1, "only msapr-header.json is expeted at the root currently") - .And.NotContainAnyEntriesInDirectoryRecursive("Assets") - .And.NotContainAnyEntriesInDirectoryRecursive("Components") - .And.NotContainAnyEntriesInDirectoryRecursive("Controls") - .And.NotContainAnyEntriesInDirectoryRecursive("References") - .And.NotContainAnyEntriesInDirectoryRecursive("Src") + .And.NotHaveAnyEntriesInDirectoryRecursive("Assets") + .And.NotHaveAnyEntriesInDirectoryRecursive("Components") + .And.NotHaveAnyEntriesInDirectoryRecursive("Controls") + .And.NotHaveAnyEntriesInDirectoryRecursive("References") + .And.NotHaveAnyEntriesInDirectoryRecursive("Src") ; } @@ -155,13 +155,13 @@ public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries() .ToList(); foreach (var originalEntry in originalEntries) { - var repackedEntry = repackedMsapp.Should().ContainEntry(originalEntry.FullName, $"repacked msapp should contain original entry").Which; + var repackedEntry = repackedMsapp.Should().HaveEntry(originalEntry.FullName, $"repacked msapp should contain original entry").Which; repackedEntry.ComputeHash().Should().Be(originalEntry.ComputeHash(), $"entry '{originalEntry.FullName}' content should be identical after round-trip"); } } // Assert: packed.json is present and correctly populated - var packedJson = repackedMsapp.Should().ContainEntry(MsappLayoutConstants.FileNames.Packed) + var packedJson = repackedMsapp.Should().HaveEntry(MsappLayoutConstants.FileNames.Packed) .Which.DeserializeAsJson(MsappSerialization.PackedJsonSerializeOptions); packedJson.PackedStructureVersion.Should().Be(PackedJson.CurrentPackedStructureVersion); packedJson.LastPackedDateTimeUtc.Should().NotBeNull(); @@ -246,7 +246,7 @@ public async Task PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames() foreach (var fileName in nonAsciiFileNames) { var expectedEntryPath = Path.Combine(MsappLayoutConstants.DirectoryNames.Src, fileName); - repackedMsapp.Should().ContainEntry(expectedEntryPath) + repackedMsapp.Should().HaveEntry(expectedEntryPath) .Which.Name.Should().Be(fileName, "the file name should be preserved exactly as written to disk"); } } @@ -273,7 +273,7 @@ public async Task PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc() // Assert: notes.txt is NOT present in the output msapp using var repackedMsapp = MsappArchiveFactory.Default.Open(outputMsappPath); - repackedMsapp.Should().NotContainEntry(Path.Combine("Src", "notes.txt"), "notes.txt is not a .pa.yaml file and should not be included in the packed msapp"); + repackedMsapp.Should().NotHaveEntry(Path.Combine("Src", "notes.txt"), "notes.txt is not a .pa.yaml file and should not be included in the packed msapp"); } [TestMethod] diff --git a/src/Persistence.Tests/Persistence.Tests.csproj b/src/Persistence.Tests/Persistence.Tests.csproj index 6b4bb9f7..435e3e28 100644 --- a/src/Persistence.Tests/Persistence.Tests.csproj +++ b/src/Persistence.Tests/Persistence.Tests.csproj @@ -30,11 +30,14 @@ + + + diff --git a/src/Persistence/Compression/PaArchiveEntry.cs b/src/Persistence/Compression/PaArchiveEntry.cs index fe457eb0..934bea89 100644 --- a/src/Persistence/Compression/PaArchiveEntry.cs +++ b/src/Persistence/Compression/PaArchiveEntry.cs @@ -7,6 +7,7 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.Compression; +[DebuggerDisplay("NormalizedPath: {NormalizedPath}")] public class PaArchiveEntry { internal PaArchiveEntry(PaArchive paArchive, ZipArchiveEntry zipEntry, PaArchivePath normalizedPath, bool skipValidation = false) diff --git a/src/Persistence/Compression/PaArchivePath.cs b/src/Persistence/Compression/PaArchivePath.cs index 5d866e12..698bc207 100644 --- a/src/Persistence/Compression/PaArchivePath.cs +++ b/src/Persistence/Compression/PaArchivePath.cs @@ -56,11 +56,6 @@ public static char[] GetInvalidPathChars() => [ // We are explicitly only wanting to support valid relative paths, so we don't allow ':' for drive letters etc. '\"', '<', '>', '|', ':', '*', '?', - - // Also adding chars which are significant to urls, PFx, yaml, json etc. to avoid issues when these paths are used in those contexts: - // Over time, we may remove some of these depending on user feedback, because they are not illegal from OS path perspectives. - '~', '!', '@', '#', '$', '%', '^', '&', ';', '+', '=', - '`', '\'', // additional quote chars ]; private static readonly System.Buffers.SearchValues _invalidPathCharsSearchValues = System.Buffers.SearchValues.Create(GetInvalidPathChars()); diff --git a/src/Persistence/Microsoft.PowerPlatform.PowerApps.Persistence.csproj b/src/Persistence/Microsoft.PowerPlatform.PowerApps.Persistence.csproj index 3a73adb0..087183f5 100644 --- a/src/Persistence/Microsoft.PowerPlatform.PowerApps.Persistence.csproj +++ b/src/Persistence/Microsoft.PowerPlatform.PowerApps.Persistence.csproj @@ -31,5 +31,6 @@ +