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
3 changes: 3 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
</PropertyGroup>

<ItemGroup>
<!-- Do not raise the FluentAssertions version above 7.x due to license changes -->
<PackageVersion Include="FluentAssertions" Version="7.2.0" />
<PackageVersion Include="JsonSchema.Net" Version="7.1.2" />

<!-- WARNING: Do not update Moq library past 4.20.0 due to privacy issues: https://github.com/devlooped/moq/issues/1372
<PackageVersion Include="Moq" Version="4.16.0" />-->
<!-- WARNING:
Moq versions 4.20.0–4.20.69 should be avoided due to the inclusion of the SponsorLink analyzer,
which accessed local git configuration and made network calls during builds. The last pre‑issue
safe version is 4.18.x; SponsorLink was removed in 4.20.70.
See: https://github.com/devlooped/moq/issues/1372
-->
<!--<PackageVersion Include="Moq" Version="4.18.2" />-->

<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
Expand Down
6 changes: 6 additions & 0 deletions src/PASopa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@

using Microsoft.Extensions.Logging;

namespace Persistence.Tests;
namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing;

/// <summary>
/// A logger implementation which captures log entries so they can be inspected in tests.
/// </summary>
internal sealed class CapturingLogger<T> : ILogger<T>
public sealed class CapturingLogger<T> : ILogger<T>
{
public record LogEntry(LogLevel Level, string Message);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using FluentAssertions;
using FluentAssertions.Execution;
using FluentAssertions.Primitives;
using FluentAssertions.Specialized;
using Microsoft.PowerPlatform.PowerApps.Persistence;
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
Expand All @@ -23,25 +24,25 @@ public static PaArchiveAssertions Should(this PaArchive? value)
public class PaArchiveAssertions(PaArchive? value)
: ObjectAssertions<PaArchive?, PaArchiveAssertions>(value)
{
public AndWhichConstraint<PaArchiveAssertions, PaArchiveEntry> ContainEntry(string fullPath, string? because = null, params object[] becauseArgs)
public AndWhichConstraint<PaArchiveAssertions, PaArchiveEntry> 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<PaArchiveAssertions> NotContainEntry(string fullPath, string? because = null, params object[] becauseArgs)
public AndConstraint<PaArchiveAssertions> 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);
Expand All @@ -64,32 +65,32 @@ public AndConstraint<PaArchiveAssertions> 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,
actualCount);
return new(this);
}

public AndConstraint<PaArchiveAssertions> NotContainAnyEntriesInDirectory(string directoryPath, string? because = null, params object[] becauseArgs)
public AndConstraint<PaArchiveAssertions> 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<PaArchiveAssertions> NotContainAnyEntriesInDirectoryRecursive(string directoryPath, string? because = null, params object[] becauseArgs)
public AndConstraint<PaArchiveAssertions> 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<PaArchiveAssertions> NotContainAnyEntriesInDirectory(string directoryPath, bool recursive, string? because = null, params object[] becauseArgs)
public AndConstraint<PaArchiveAssertions> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Persistence.Tests;
namespace Microsoft.PowerPlatform.PowerApps.Persistence.Testing;

/// <summary>
/// An implementation of <see cref="IComparer{T}"/> 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.
/// </summary>
public class FilePathComparer : IComparer<string>
{
public static readonly FilePathComparer Instance = new();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<SignAssembly>true</SignAssembly>
<PublicSign>true</PublicSign>
</PropertyGroup>

<PropertyGroup Label="Nuget Properties">
<Title>Microsoft Power Platform Canvas App Persistence Testing Library</Title>
<Description>Preview Release</Description>
<PackageReleaseNotes>
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.
</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Persistence\Microsoft.PowerPlatform.PowerApps.Persistence.csproj" />
</ItemGroup>

</Project>
45 changes: 36 additions & 9 deletions src/Persistence.Tests/Compression/PaArchivePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 3 additions & 3 deletions src/Persistence.Tests/Compression/PaArchiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 10 additions & 11 deletions src/Persistence.Tests/Extensions/PersistenceFluentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ namespace Persistence.Tests.Extensions;
/// <summary>
/// Extensions for tests.
/// </summary>
internal static class PersistenceFluentExtensions
internal static partial class PersistenceFluentExtensions
{
/// <summary>
/// Asserts that the value is not null and gives the nullable assertion.
/// Will result in a FluentAssertionException if the value is null.
/// </summary>
public static void ShouldNotBeNull<T>([NotNull] this T? value)
public static void ShouldNotBeNull<T>([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.");
}
}
Expand Down Expand Up @@ -62,7 +62,7 @@ public static AndConstraint<TAssertions> BeYamlEquivalentTo<TAssertions>(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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand All @@ -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}\"]";
}
Expand Down
7 changes: 6 additions & 1 deletion src/Persistence.Tests/MsApp/MsappArchiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading