diff --git a/.gitignore b/.gitignore
index 25ed03be..f75bed67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -223,6 +223,8 @@ _pkginfo.txt
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
+# C# Dev Kit cache files
+*.lscache
# Others
ClientBin/
diff --git a/src/PAModel/CanvasDocument.cs b/src/PAModel/CanvasDocument.cs
index 5b196cd9..e22a799b 100644
--- a/src/PAModel/CanvasDocument.cs
+++ b/src/PAModel/CanvasDocument.cs
@@ -16,22 +16,22 @@
namespace Microsoft.PowerPlatform.Formulas.Tools;
///
-/// Represents a PowerApps document. This can be save/loaded from a MsApp or Source representation.
-/// This is a full in-memory representation of the msapp file.
+/// Represents a PowerApps document. This can be save/loaded from a MsApp or Source representation.
+/// This is a full in-memory representation of the msapp file.
///
public class CanvasDocument
{
///
- /// Current source format version.
+ /// Current source format version.
///
public static Version CurrentSourceVersion => SourceSerializer.CurrentSourceVersion;
// Rules for CanvasDocument
- // - Save/Load must faithfully roundtrip an msapp exactly.
- // - this is an in-memory representation - so it must parse/shard everything on load.
- // - Save should not mutate any state.
+ // - Save/Load must faithfully roundtrip an msapp exactly.
+ // - this is an in-memory representation - so it must parse/shard everything on load.
+ // - Save should not mutate any state.
- // Track all unknown "files". Ensures round-tripping isn't lossy.
+ // Track all unknown "files". Ensures round-tripping isn't lossy.
// Only contains files of FileKind.Unknown
internal Dictionary _unknownFiles = new();
@@ -42,7 +42,7 @@ public class CanvasDocument
internal EditorStateStore _editorStateStore;
internal TemplateStore _templateStore;
- // Various data sources
+ // Various data sources
// This is references\dataSources.json
// Also includes entries for DataSources made from a DataComponent
// Key is parent entity name (datasource name for non cds data sources)
@@ -76,7 +76,7 @@ public class CanvasDocument
internal IDictionary _dataSourceReferences;
// Extracted from _properties.LibraryDependencies
- // Must preserve server ordering.
+ // Must preserve server ordering.
internal ComponentDependencyInfo[] _libraryReferences;
internal FileEntry _logoFile;
@@ -84,7 +84,7 @@ public class CanvasDocument
// Save for roundtripping.
internal Entropy _entropy = new();
- // Checksum from existing msapp.
+ // Checksum from existing msapp.
internal ChecksumJson _checksum;
// Track all asset files, key is file name
@@ -102,7 +102,7 @@ public class CanvasDocument
#region Save/Load
///
- /// Load an .msapp file for a Canvas Document.
+ /// Load an .msapp file for a Canvas Document.
///
/// path to an .msapp file
/// A tuple of the document and errors and warnings. If there are errors, the document is null.
@@ -178,32 +178,28 @@ public ErrorContainer SaveToSources(string pathToSourceDirectory, string verifyO
var errors = new ErrorContainer();
Wrapper(() => SourceSerializer.SaveAsSource(this, pathToSourceDirectory, errors), errors);
-
// Test that we can repack
if (!errors.HasErrors && verifyOriginalPath != null)
{
- (var msApp2, var errors2) = LoadFromSources(pathToSourceDirectory);
+ var (msApp2, errors2) = LoadFromSources(pathToSourceDirectory);
if (errors2.HasErrors)
{
errors2.PostUnpackValidationFailed();
return errors2;
}
- using (var temp = new TempFile())
+ using var temp = new TempFile();
+ errors2 = msApp2.SaveToMsAppValidation(temp.FullPath);
+ if (errors2.HasErrors)
{
- errors2 = msApp2.SaveToMsAppValidation(temp.FullPath);
- if (errors2.HasErrors)
- {
- errors2.PostUnpackValidationFailed();
- return errors2;
- }
+ errors2.PostUnpackValidationFailed();
+ return errors2;
+ }
- var ok = MsAppTest.Compare(verifyOriginalPath, temp.FullPath, TextWriter.Null, errors2);
- if (!ok)
- {
- errors2.PostUnpackValidationFailed();
- return errors2;
- }
+ if (!MsAppTest.Compare(verifyOriginalPath, temp.FullPath, errors2))
+ {
+ errors2.PostUnpackValidationFailed();
+ return errors2;
}
}
@@ -218,7 +214,7 @@ public static (CanvasDocument, ErrorContainer) MakeFromSources(string appName, s
#endregion
- // Wrapper to ensure consistent invariants between loading a document, exception handling, and returning errors.
+ // Wrapper to ensure consistent invariants between loading a document, exception handling, and returning errors.
private static CanvasDocument Wrapper(Func worker, ErrorContainer errors)
{
try
@@ -320,7 +316,7 @@ internal CanvasDocument(CanvasDocument other)
_localAssetInfoJson = other._localAssetInfoJson.JsonClone();
}
- // iOrder is used to preserve ordering value for round-tripping.
+ // iOrder is used to preserve ordering value for round-tripping.
internal void AddDataSourceForLoad(DataSourceEntry ds, int? order = null)
{
// Key is parent entity name
@@ -376,7 +372,7 @@ internal void ApplyAfterMsAppLoadTransforms(ErrorContainer errors)
var componentInstanceTransform = new ComponentInstanceTransform(errors);
var componentDefTransform = new ComponentDefinitionTransform(errors, _templateStore, componentInstanceTransform);
- // Transform component definitions and populate template set of component instances that need updates
+ // Transform component definitions and populate template set of component instances that need updates
foreach (var ctrl in _components)
{
AddComponentDefaults(ctrl.Value, templateDefaults);
@@ -433,7 +429,7 @@ internal void ApplyBeforeMsAppWriteTransforms(ErrorContainer errors)
var componentInstanceTransform = new ComponentInstanceTransform(errors);
var componentDefTransform = new ComponentDefinitionTransform(errors, _templateStore, componentInstanceTransform);
- // Transform component definitions and populate template set of component instances that need updates
+ // Transform component definitions and populate template set of component instances that need updates
foreach (var ctrl in _components)
{
componentDefTransform.BeforeWrite(ctrl.Value);
@@ -466,15 +462,16 @@ private void AddComponentDefaults(BlockNode topParent, Dictionary GetImportedComponents()
{
var set = new HashSet();
@@ -679,10 +675,10 @@ private void RestoreAssetFilePaths(ErrorContainer errors)
}
}
- // Helper for traversing and ensuring unique control names.
+ // Helper for traversing and ensuring unique control names.
internal class UniqueControlNameVisitor
{
- // Control names are case sensitive.
+ // Control names are case sensitive.
private readonly Dictionary _names = new(StringComparer.Ordinal);
private readonly ErrorContainer _errors;
@@ -693,7 +689,7 @@ public UniqueControlNameVisitor(ErrorContainer errors)
public void Visit(BlockNode node)
{
- // Ignore test templates here.
+ // Ignore test templates here.
// Test templates have control-like syntax, but allowed to repeat names:
// Step4 As TestStep:
if (AppTestTransform.IsTestSuite(node.Name.Kind.TypeName))
diff --git a/src/PAModel/Checksum/ChecksumMaker.cs b/src/PAModel/Checksum/ChecksumMaker.cs
index ccd16014..c702fe9e 100644
--- a/src/PAModel/Checksum/ChecksumMaker.cs
+++ b/src/PAModel/Checksum/ChecksumMaker.cs
@@ -70,8 +70,7 @@ internal static byte[] ChecksumFile(string filename, byte[] bytes)
if (filename.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
filename.EndsWith(".sarif", StringComparison.OrdinalIgnoreCase))
{
- var key = ChecksumJsonFile(filename, bytes);
- return key;
+ return ChecksumJsonFile(filename, bytes);
}
else
{
@@ -164,20 +163,14 @@ private static byte[] ChecksumJsonFile(string filename, byte[] bytes)
where T : IHashMaker, new()
{
var s = new ReadOnlyMemory(bytes);
- using (var doc = JsonDocument.Parse(s))
- {
- var je = doc.RootElement;
-
- var ctx = new Context { Filename = filename };
+ using var doc = JsonDocument.Parse(s);
+ var je = doc.RootElement;
- using (var hash = new T())
- {
- ChecksumJson(ctx, hash, je);
+ var ctx = new Context { Filename = filename };
- var key = hash.GetFinalValue();
- return key;
- }
- }
+ using var hash = new T();
+ ChecksumJson(ctx, hash, je);
+ return hash.GetFinalValue();
}
internal static string ChecksumToString(byte[] bytes)
diff --git a/src/PAModel/Entropy.cs b/src/PAModel/Entropy.cs
index 09a7dff7..1e33e6e6 100644
--- a/src/PAModel/Entropy.cs
+++ b/src/PAModel/Entropy.cs
@@ -90,12 +90,12 @@ internal class PropertyEntropy
public bool? WasLocalDatabaseReferencesEmpty { get; set; }
///
- /// Tracks whether TestStepsMetadata is empty or not.
+ /// Indicates when a Test has missing steps metadata property; which happens UX creates a new test, but the user doesn't add a step.
///
- public bool? DoesTestStepsMetadataExist { get; set; }
+ public HashSet AppTestsMissingStepsMetadata { get; set; } = new HashSet(StringComparer.Ordinal);
// Key is connection id, value is connection instance id
- public Dictionary LocalConnectionIDReferences { get; set; } = new Dictionary(StringComparer.Ordinal);
+ public Dictionary LocalConnectionIDReferences { get; set; } = new Dictionary();
// Key is test rule, value is test screen id without Screen name
public Dictionary RuleScreenIdWithoutScreen { get; set; } = new Dictionary(StringComparer.Ordinal);
diff --git a/src/PAModel/MsAppTest.cs b/src/PAModel/MsAppTest.cs
index 1d5a0181..1c06bb8c 100644
--- a/src/PAModel/MsAppTest.cs
+++ b/src/PAModel/MsAppTest.cs
@@ -14,41 +14,31 @@ namespace Microsoft.PowerPlatform.Formulas.Tools;
internal class MsAppTest
{
- public static bool Compare(CanvasDocument doc1, CanvasDocument doc2, TextWriter log)
+ public static bool Compare(CanvasDocument doc1, CanvasDocument doc2)
{
- using (var temp1 = new TempFile())
- using (var temp2 = new TempFile())
- {
- doc1.SaveToMsApp(temp1.FullPath);
- doc2.SaveToMsApp(temp2.FullPath);
- return Compare(temp1.FullPath, temp2.FullPath, log);
- }
+ using var temp1 = new TempFile();
+ using var temp2 = new TempFile();
+ doc1.SaveToMsApp(temp1.FullPath);
+ doc2.SaveToMsApp(temp2.FullPath);
+ return Compare(temp1.FullPath, temp2.FullPath);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")]
public static bool MergeStressTest(string pathToMsApp1, string pathToMsApp2)
{
- try
- {
- (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp1);
- errors.ThrowOnErrors();
+ (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp1);
+ errors.ThrowOnErrors();
- (var doc2, var errors2) = CanvasDocument.LoadFromMsapp(pathToMsApp2);
- errors2.ThrowOnErrors();
+ (var doc2, var errors2) = CanvasDocument.LoadFromMsapp(pathToMsApp2);
+ errors2.ThrowOnErrors();
- var doc1New = CanvasMerger.Merge(doc1, doc2, doc2);
- var ok1 = HasNoDeltas(doc1, doc1New);
+ var doc1New = CanvasMerger.Merge(doc1, doc2, doc2);
+ var ok1 = HasNoDeltas(doc1, doc1New);
- var doc2New = CanvasMerger.Merge(doc2, doc1, doc1);
- var ok2 = HasNoDeltas(doc2, doc2New);
+ var doc2New = CanvasMerger.Merge(doc2, doc1, doc1);
+ var ok2 = HasNoDeltas(doc2, doc2New);
- return ok1 && ok2;
- }
- catch (Exception e)
- {
- Console.WriteLine(e.Message);
- return false;
- }
+ return ok1 && ok2;
}
public static bool TestClone(string pathToMsApp)
@@ -70,159 +60,136 @@ public static bool DiffStressTest(string pathToMsApp)
}
// Verify there are no deltas (detected via smart merge) between doc1 and doc2
- // Strict =true, also compare entropy files.
+ // Strict =true, also compare entropy files.
private static bool HasNoDeltas(CanvasDocument doc1, CanvasDocument doc2, bool strict = false)
{
var ourDeltas = Diff.ComputeDelta(doc1, doc1);
// ThemeDelta always added
- ourDeltas = ourDeltas.Where(x => x.GetType() != typeof(ThemeChange)).ToArray();
+ ourDeltas = ourDeltas.Where(x => x.GetType() != typeof(ThemeChange));
if (ourDeltas.Any())
{
- foreach (var diff in ourDeltas)
- {
- Console.WriteLine($" {diff.GetType().Name}");
- }
// Error! app shouldn't have any diffs with itself.
return false;
}
// Save and verify checksums.
- using (var temp1 = new TempFile())
- using (var temp2 = new TempFile())
- {
- doc1.SaveToMsApp(temp1.FullPath);
- doc2.SaveToMsApp(temp2.FullPath);
+ using var temp1 = new TempFile();
+ using var temp2 = new TempFile();
+ doc1.SaveToMsApp(temp1.FullPath);
+ doc2.SaveToMsApp(temp2.FullPath);
- bool same;
- if (strict)
- {
- same = Compare(temp1.FullPath, temp2.FullPath, Console.Out);
- }
- else
- {
- var doc1NoEntropy = RemoveEntropy(temp1.FullPath);
- var doc2NoEntropy = RemoveEntropy(temp2.FullPath);
-
- same = Compare(doc1NoEntropy, doc2NoEntropy, Console.Out);
- }
+ bool same;
+ if (strict)
+ {
+ same = Compare(temp1.FullPath, temp2.FullPath);
+ }
+ else
+ {
+ var doc1NoEntropy = RemoveEntropy(temp1.FullPath);
+ var doc2NoEntropy = RemoveEntropy(temp2.FullPath);
- if (!same)
- {
- return false;
- }
+ same = Compare(doc1NoEntropy, doc2NoEntropy);
}
- return true;
+ return same;
}
- // Unpack, delete the entropy dirs, repack.
+ // Unpack, delete the entropy dirs, repack.
public static CanvasDocument RemoveEntropy(string pathToMsApp)
{
- using (var temp1 = new TempDir())
- {
- (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp);
- errors.ThrowOnErrors();
+ using var temp1 = new TempDir();
+ (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp);
+ errors.ThrowOnErrors();
- doc1.SaveToSources(temp1.Dir);
+ doc1.SaveToSources(temp1.Dir);
- var entropyDir = Path.Combine(temp1.Dir, "Entropy");
- if (!Directory.Exists(entropyDir))
- {
- throw new Exception($"Missing entropy dir: " + entropyDir);
- }
+ var entropyDir = Path.Combine(temp1.Dir, "Entropy");
+ if (!Directory.Exists(entropyDir))
+ {
+ throw new Exception($"Missing entropy dir: " + entropyDir);
+ }
- Directory.Delete(entropyDir, recursive: true);
- (var doc2, _) = CanvasDocument.LoadFromSources(temp1.Dir);
- errors.ThrowOnErrors();
+ Directory.Delete(entropyDir, recursive: true);
+ (var doc2, _) = CanvasDocument.LoadFromSources(temp1.Dir);
+ errors.ThrowOnErrors();
- return doc2;
- }
+ return doc2;
}
- // Given an msapp (original source of truth), stress test the conversions
+ ///
+ /// Given an msapp (original source of truth), stress test the conversions
+ ///
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")]
public static bool StressTest(string pathToMsApp)
{
- try
+ using (var temp1 = new TempFile())
{
- using (var temp1 = new TempFile())
- {
- var outFile = temp1.FullPath;
-
- var log = TextWriter.Null;
+ var outFile = temp1.FullPath;
- // MsApp --> Model
- CanvasDocument msapp;
- var errors = new ErrorContainer();
- try
- {
- using (var stream = new FileStream(pathToMsApp, FileMode.Open, FileAccess.Read, FileShare.Read))
- {
- msapp = MsAppSerializer.Load(stream, errors);
- }
- errors.Write(log);
- errors.ThrowOnErrors();
-
- // We can still get warnings here. Commonly:
- // - PA2001, checksum mismatch
- // - PA2999, colliding asset names
- }
- catch (NotSupportedException)
+ // MsApp --> Model
+ CanvasDocument msapp;
+ var errors = new ErrorContainer();
+ try
+ {
+ using (var stream = new FileStream(pathToMsApp, FileMode.Open, FileAccess.Read, FileShare.Read))
{
- errors.FormatNotSupported($"Too old: {pathToMsApp}");
- return false;
+ msapp = MsAppSerializer.Load(stream, errors);
}
-
- // Model --> MsApp
- errors = msapp.SaveToMsApp(outFile);
errors.ThrowOnErrors();
- var ok = Compare(pathToMsApp, outFile, log);
- if (!ok) { return false; }
-
-
- // Model --> Source
- using (var tempDir = new TempDir())
- {
- var outSrcDir = tempDir.Dir;
- errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp);
- errors.ThrowOnErrors();
- }
- } // end using
- if (!TestClone(pathToMsApp))
+ // We can still get warnings here. Commonly:
+ // - PA2001, checksum mismatch
+ // - PA2999, colliding asset names
+ }
+ catch (NotSupportedException)
{
+ errors.FormatNotSupported($"Too old: {pathToMsApp}");
return false;
}
- if (!DiffStressTest(pathToMsApp))
+ // Model --> MsApp
+ errors = msapp.SaveToMsApp(outFile);
+ errors.ThrowOnErrors();
+
+ if (!Compare(pathToMsApp, outFile, errors))
{
+ errors.ThrowOnErrors();
return false;
}
+
+ // Model --> Source
+ using var tempDir = new TempDir();
+ var outSrcDir = tempDir.Dir;
+ errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp);
+ errors.ThrowOnErrors();
+ } // end using
+
+ if (!TestClone(pathToMsApp))
+ {
+ return false;
}
- catch (Exception e)
+
+ if (!DiffStressTest(pathToMsApp))
{
- Console.WriteLine(e.ToString());
return false;
}
return true;
}
- public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log)
+ public static bool Compare(string pathToZip1, string pathToZip2)
{
var errorContainer = new ErrorContainer();
- return Compare(pathToZip1, pathToZip2, log, errorContainer);
+ return Compare(pathToZip1, pathToZip2, errorContainer);
}
// Overload with ErrorContainer
- public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log, ErrorContainer errorContainer)
+ public static bool Compare(string pathToZip1, string pathToZip2, ErrorContainer errorContainer)
{
- var c1 = ChecksumMaker.GetChecksum(pathToZip1);
- var c2 = ChecksumMaker.GetChecksum(pathToZip2);
- if (c1.wholeChecksum == c2.wholeChecksum)
+ if (ChecksumMaker.GetChecksum(pathToZip1).wholeChecksum == ChecksumMaker.GetChecksum(pathToZip2).wholeChecksum)
{
return true;
}
@@ -230,18 +197,18 @@ public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log,
// Provide a comparison that can be very specific about what the difference is.
var comp = new Dictionary();
- CompareChecksums(pathToZip1, log, comp, true, errorContainer);
- CompareChecksums(pathToZip2, log, comp, false, errorContainer);
+ CompareChecksums(pathToZip1, comp, true, errorContainer);
+ CompareChecksums(pathToZip2, comp, false, errorContainer);
return false;
}
- // Compare the debug checksums.
+ // Compare the debug checksums.
// Get a hash for the MsApp file.
// First pass adds file/hash to comp.
// Second pass checks hash equality and removes files from comp.
// After second pass, comp should be 0. Any files in comp were missing from 2nd pass.
- public static void CompareChecksums(string pathToZip, TextWriter log, Dictionary comp, bool first, ErrorContainer errorContainer)
+ private static void CompareChecksums(string pathToZip, Dictionary comp, bool first, ErrorContainer errorContainer)
{
// Path to the directory where we are creating the normalized form
var normFormDir = ".\\diffFiles";
@@ -252,191 +219,161 @@ public static void CompareChecksums(string pathToZip, TextWriter log, Dictionary
Directory.CreateDirectory(normFormDir);
}
- using (var zip = ZipFile.OpenRead(pathToZip))
+ using var zip = ZipFile.OpenRead(pathToZip);
+ foreach (var entry in zip.Entries.OrderBy(x => x.FullName))
{
- foreach (var entry in zip.Entries.OrderBy(x => x.FullName))
+ var newContents = ChecksumMaker.ChecksumFile(entry.FullName, entry.ToBytes());
+ if (newContents == null)
+ {
+ continue;
+ }
+
+ // Do easy diffs
+ var entryFullName = entry.FullName;
+ if (first)
{
- var newContents = ChecksumMaker.ChecksumFile(entry.FullName, entry.ToBytes());
- if (newContents == null)
+ comp.Add(entryFullName, newContents);
+ }
+ else
+ {
+ if (comp.TryGetValue(entryFullName, out var originalContents))
{
- continue;
- }
+ CompareEntryContents(entryFullName, originalContents, newContents, errorContainer);
- // Do easy diffs
+ comp.Remove(entryFullName);
+ }
+ else
{
- if (first)
- {
- comp.Add(entry.FullName, newContents);
- }
- else
- {
- if (comp.TryGetValue(entry.FullName, out var originalContents))
- {
- var same = newContents.SequenceEqual(originalContents);
-
- if (!same)
- {
-
- var isJson = true;
-
- // Catch in case of originalContents/newContents not being JSON
- try
- {
- JsonDocument.Parse(originalContents);
- JsonDocument.Parse(newContents);
- }
- catch (JsonException)
- {
- isJson = false;
- }
-
- if (isJson)
- {
- var jsonDictionary1 = FlattenJson(originalContents);
- var jsonDictionary2 = FlattenJson(newContents);
-
- // Add JSONMismatch error if JSON property was changed or removed
- CheckPropertyChangedRemoved(jsonDictionary1, jsonDictionary2, errorContainer, "");
-
- // Add JSONMismatch error if JSON property was added
- CheckPropertyAdded(jsonDictionary1, jsonDictionary2, errorContainer, "");
- }
-
-#if DEBUG
- //DebugMismatch(entry, originalContents, newContents, normFormDir);
-#endif
-
- if (!isJson)
- {
- throw new ArgumentException($"Mismatch detected in non-Json properties: " + entry.FullName);
- }
- }
-
- comp.Remove(entry.FullName);
- }
- else
- {
- // Missing file!
- Console.WriteLine("FAIL: 2nd has added file: " + entry.FullName);
- }
- }
+ // Missing file!
+ errorContainer.AddedZipEntry(entryFullName);
}
}
}
}
- public static Dictionary FlattenJson(byte[] json)
+ private static void CompareEntryContents(string entryFullName, byte[] originalContents, byte[] newContents, ErrorContainer errorContainer)
{
- using (var document = JsonDocument.Parse(json))
+ if (!newContents.SequenceEqual(originalContents))
{
- var jsonObject = document.RootElement.EnumerateObject().SelectMany(property => GetLeaves(null, property));
- return jsonObject.ToDictionary(key => key.Path, value => value.Property.Value.Clone());
+ // Catch in case of originalContents/newContents not being JSON
+ try
+ {
+ JsonDocument.Parse(originalContents);
+ JsonDocument.Parse(newContents);
+ }
+ catch (JsonException ex)
+ {
+ throw new ArgumentException($"Mismatch detected in non-Json properties: " + entryFullName, ex);
+ }
+
+ var flattenedJsonOrig = FlattenJson(originalContents);
+ var flattenedJsonNew = FlattenJson(newContents);
+
+ // Add JSONMismatch error if JSON property was changed or removed
+ CheckPropertyChangedRemoved(entryFullName, flattenedJsonOrig, flattenedJsonNew, errorContainer);
+
+ // Add JSONMismatch error if JSON property was added
+ CheckPropertyAdded(entryFullName, flattenedJsonOrig, flattenedJsonNew, errorContainer);
}
+ }
+ public static Dictionary FlattenJson(byte[] json)
+ {
+ using var document = JsonDocument.Parse(json);
+ return FlattenJson(string.Empty, document.RootElement)
+ .ToDictionary(t => t.Path, t => t.Value.Clone());
}
- public static IEnumerable<(string Path, JsonProperty Property)> GetLeaves(string path, JsonProperty property)
+ private static IEnumerable<(string Path, JsonElement Value)> FlattenJson(string path, JsonElement value)
{
- if (path == null)
- {
- path = property.Name;
- }
- else
- {
- path += "." + property.Name;
- }
+ Debug.Assert(path is not null);
- if (property.Value.ValueKind == JsonValueKind.Object)
+ if (value.ValueKind == JsonValueKind.Object)
{
- return property.Value.EnumerateObject().SelectMany(child => GetLeaves(path, child));
+ return FlattenObject(path, value);
}
- else if (property.Value.ValueKind == JsonValueKind.Array)
+ else if (value.ValueKind == JsonValueKind.Array)
{
- if (property.Value.GetArrayLength() == 0)
+ // Only flatten array if it has an object as one of its items. Otherwise, treat the entire array as a leaf.
+ if (value.EnumerateArray().Any(item => item.ValueKind == JsonValueKind.Object))
{
- return new[] { (path, property) };
- }
- else
- {
- var arrayType = property.Value[0].ValueKind;
-
- // Peek, if member types, return
- if (arrayType == JsonValueKind.Object)
- {
- return FlattenArray(path, property.Value);
- }
- else
- {
- return new[] { (path, property) };
- }
+ return FlattenArray(path, value);
}
}
- else
+
+ return [(path, value)];
+ }
+
+ public static IEnumerable<(string Path, JsonElement Value)> FlattenObject(string path, JsonElement jsonObject)
+ {
+ Debug.Assert(path is not null);
+ Debug.Assert(jsonObject.ValueKind == JsonValueKind.Object);
+
+ // pre-append the '.' operator if not the root object
+ if (path.Length > 0)
{
- return new[] { (path, property) };
+ path += ".";
}
+
+ return jsonObject.EnumerateObject()
+ .SelectMany(property => FlattenJson(path + property.Name, property.Value));
}
- public static IEnumerable<(string Path, JsonProperty Property)> FlattenArray(string path, JsonElement array)
- {
- var enumeratedObjects = new List<(string arrayPath, JsonProperty arrayProperty)>();
- var index = 0;
+ public static IEnumerable<(string Path, JsonElement Value)> FlattenArray(string path, JsonElement jsonArray)
+ {
+ Debug.Assert(path is not null);
+ Debug.Assert(jsonArray.ValueKind == JsonValueKind.Array);
- foreach (var member in array.EnumerateArray())
- {
- var arraySubPath = $"{path}[{index}]";
+ var isRulesArray = path.StartsWith("TopParent.") && path.EndsWith(".Rules");
- if (member.ValueKind == JsonValueKind.Object)
+ return jsonArray.EnumerateArray()
+ .SelectMany((arrayItem, index) =>
{
- enumeratedObjects.AddRange(member.EnumerateObject().SelectMany(child => GetLeaves(arraySubPath, child)));
- }
+ var arraySubPath = $"{path}[{index}]";
- index++;
- }
- return enumeratedObjects.ToArray();
+ // For Rules arrays, use the Property name as the key instead of the index, since order doesn't matter and Property is (likely) unique
+ if (isRulesArray && arrayItem.ValueKind == JsonValueKind.Object && arrayItem.TryGetProperty("Property", out var propertyName))
+ {
+ arraySubPath = $"{path}['{propertyName.GetString()}']";
+ }
+
+ return FlattenJson(arraySubPath, arrayItem);
+ });
}
- public static void CheckPropertyChangedRemoved(Dictionary dictionary1, Dictionary dictionary2, ErrorContainer errorContainer, string jsonPath)
+ public static void CheckPropertyChangedRemoved(string entryFullName, Dictionary flattenedJsonOrig, Dictionary flattenedJsonNew, ErrorContainer errorContainer)
{
- // Iterate through each path/json pair in Dictionary 1
- foreach (var currentPair1 in dictionary1)
+ foreach (var kvpOrig in flattenedJsonOrig)
{
- // Check if the second dictionary contains the same key as in Dictionary 1
- if (dictionary2.TryGetValue(currentPair1.Key, out var json2))
+ // Check if the property exists in the new JSON
+ if (flattenedJsonNew.TryGetValue(kvpOrig.Key, out var newJsonValue))
{
- // Check if the value in Dictionary 2's property is equal to the value in Dictionary1's property
- if (!currentPair1.Value.GetRawText().Equals(json2.GetRawText()))
+ // Check if the raw value is different, if so, it's a mismatch
+ if (!kvpOrig.Value.GetRawText().Equals(newJsonValue.GetRawText()))
{
- errorContainer.JSONValueChanged(currentPair1.Key);
+ errorContainer.JSONValueChanged(entryFullName, kvpOrig.Key);
}
-
}
- // If current property from first file does not exist in second
- else
+ else // Then the property was removed
{
- errorContainer.JSONPropertyRemoved(currentPair1.Key);
+ errorContainer.JSONPropertyRemoved(entryFullName, kvpOrig.Key);
}
-
}
}
- public static void CheckPropertyAdded(Dictionary dictionary1, Dictionary dictionary2, ErrorContainer errorContainer, string jsonPath)
+ public static void CheckPropertyAdded(string entryFullName, Dictionary flattenedJsonOrig, Dictionary flattenedJsonNew, ErrorContainer errorContainer)
{
- // Check each property and value in json1 to see if each exists and is equal to json2
- foreach (var currentPair2 in dictionary2)
+ // Report any properties in new that are not in original
+ foreach (var newPath in flattenedJsonNew.Keys.Except(flattenedJsonOrig.Keys))
{
- // If current property from second json file does not exist in the first file
- if (!dictionary1.ContainsKey(currentPair2.Key))
- {
- errorContainer.JSONPropertyAdded(currentPair2.Key);
- }
+ errorContainer.JSONPropertyAdded(entryFullName, newPath);
}
}
public static void DebugMismatch(ZipArchiveEntry entry, byte[] originalContents, byte[] newContents, string normFormDir)
{
// Fail! Mismatch
- Console.WriteLine("FAIL: hash mismatch: " + entry.FullName);
+ //Console.WriteLine("FAIL: hash mismatch: " + entry.FullName);
// Paths to current diff files
var aPath = normFormDir + "\\" + Path.ChangeExtension(entry.Name, null) + "-A.json";
diff --git a/src/PAModel/PAConvert/Error.cs b/src/PAModel/PAConvert/Error.cs
index 4479df5f..1f282c3c 100644
--- a/src/PAModel/PAConvert/Error.cs
+++ b/src/PAModel/PAConvert/Error.cs
@@ -29,6 +29,21 @@ internal Error(ErrorCode code, SourceLocation span, string message)
public override string ToString()
{
var sb = new StringBuilder();
+ WriteTo(sb);
+ return sb.ToString();
+ }
+
+ internal void WriteTo(StringBuilder sb)
+ {
+ // Format using VS error format
+ // 1>E:\repos\github\microsoft\PA-Tooling\src\PAModel\PAConvert\Error.cs(42,11,42,11): error CS1002: ; expected
+ var origLen = sb.Length;
+ Span.WriteTo(sb);
+ if (sb.Length != origLen)
+ {
+ sb.Append(": ");
+ }
+
if (IsError)
{
sb.Append("Error ");
@@ -37,9 +52,8 @@ public override string ToString()
{
sb.Append("Warning ");
}
+
sb.Append($"PA{(int)Code}: ");
sb.Append(Message);
-
- return sb.ToString();
}
}
diff --git a/src/PAModel/PAConvert/ErrorCode.cs b/src/PAModel/PAConvert/ErrorCode.cs
index 1cfcbbdf..b9a9c7ad 100644
--- a/src/PAModel/PAConvert/ErrorCode.cs
+++ b/src/PAModel/PAConvert/ErrorCode.cs
@@ -67,6 +67,8 @@ internal enum ErrorCode
// JSON Property was removed
JSONPropertyRemoved = 3015,
+ ArchiveEntryAdded = 3101,
+
// Catch-all. Should review and make these more specific.
Generic = 3999,
@@ -168,20 +170,26 @@ public static void MsAppFormatError(this ErrorContainer errors, string message)
errors.AddError(ErrorCode.CantReadMsApp, default, $"MsApp is corrupted: {message}");
}
- public static void JSONValueChanged(this ErrorContainer errors, string message)
+ public static void JSONValueChanged(this ErrorContainer errors, string entryPath, string jsonPath)
{
- errors.AddError(ErrorCode.JSONValueChanged, default, $"Property Value Changed: {message}");
+ errors.AddError(ErrorCode.JSONValueChanged, SourceLocation.FromFile(entryPath), $"Property Value Changed: {jsonPath}");
}
- public static void JSONPropertyAdded(this ErrorContainer errors, string message)
+ public static void JSONPropertyAdded(this ErrorContainer errors, string entryPath, string jsonPath)
{
- errors.AddError(ErrorCode.JSONPropertyAdded, default, $"Property Added: {message}");
+ errors.AddError(ErrorCode.JSONPropertyAdded, SourceLocation.FromFile(entryPath), $"Property Added: {jsonPath}");
}
- public static void JSONPropertyRemoved(this ErrorContainer errors, string message)
+ public static void JSONPropertyRemoved(this ErrorContainer errors, string entryPath, string jsonPath)
{
- errors.AddError(ErrorCode.JSONPropertyRemoved, default, $"Property Removed: {message}");
+ errors.AddError(ErrorCode.JSONPropertyRemoved, SourceLocation.FromFile(entryPath), $"Property Removed: {jsonPath}");
}
+
+ public static void AddedZipEntry(this ErrorContainer errors, string entryPath)
+ {
+ errors.AddError(ErrorCode.ArchiveEntryAdded, SourceLocation.FromFile(entryPath), "Archive entry added.");
+ }
+
public static void UnsupportedError(this ErrorContainer errors, string message)
{
errors.AddError(ErrorCode.UnsupportedError, default, $"Not Supported: {message}");
diff --git a/src/PAModel/PAConvert/Parser/SourceLocation.cs b/src/PAModel/PAConvert/Parser/SourceLocation.cs
index cb96aabb..aa8861f6 100644
--- a/src/PAModel/PAConvert/Parser/SourceLocation.cs
+++ b/src/PAModel/PAConvert/Parser/SourceLocation.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using System.Linq;
+using System.Text;
namespace Microsoft.PowerPlatform.Formulas.Tools.IR;
@@ -30,7 +31,37 @@ public static SourceLocation FromFile(string filename)
public override string ToString()
{
- return $"{FileName}:{StartLine},{StartChar}-{EndLine},{EndChar}";
+ var sb = new StringBuilder();
+ WriteTo(sb);
+ return sb.ToString();
+ }
+
+ ///
+ /// Writes this location to the given StringBuilder in the format "filename(startLine,startChar,endLine,endChar)".
+ /// If the filename is empty, it is omitted.
+ /// If the start and end positions are all zeros, the end position is omitted.
+ ///
+ internal void WriteTo(StringBuilder sb)
+ {
+ // Format using VS error format
+ // 1>src\PAModel\PAConvert\Error.cs(42,11,42,11): error CS1002: ; expected
+ if (FileName is not null)
+ {
+ sb.Append(FileName);
+ }
+
+ if (StartLine != default || StartChar != default || EndLine != default || EndChar != default)
+ {
+ sb.Append('(');
+ sb.Append(StartLine);
+ sb.Append(',');
+ sb.Append(StartChar);
+ sb.Append(',');
+ sb.Append(EndLine);
+ sb.Append(',');
+ sb.Append(EndChar);
+ sb.Append(')');
+ }
}
public static SourceLocation FromChildren(List locations)
diff --git a/src/PAModel/SourceTransforms/AppTestTransform.cs b/src/PAModel/SourceTransforms/AppTestTransform.cs
index c51c45a4..4c3a46f4 100644
--- a/src/PAModel/SourceTransforms/AppTestTransform.cs
+++ b/src/PAModel/SourceTransforms/AppTestTransform.cs
@@ -56,21 +56,21 @@ public void AfterRead(BlockNode control)
var properties = control.Properties.ToDictionary(prop => prop.Identifier);
if (!properties.TryGetValue(_metadataPropName, out var metadataProperty))
{
- // If no metadata props, TestStepsMetadata nonexistent
- _entropy.DoesTestStepsMetadataExist = false;
-
// If the test studio is opened, but no tests are created, it's possible for a test case to exist without any
- // steps or teststepmetadata. In that case, write only the base properties.
- if (properties.Count == 2)
- return;
+ // steps or teststepmetadata. Save to entropy if this is the case.
+ // This is only needed for round-trip validation; when packing, we'll always create an empty-array property if no steps exist.
+ _entropy.AppTestsMissingStepsMetadata.Add(control.Name.Identifier);
- _errors.ValidationError($"Unable to find TestStepsMetadata property for TestCase {control.Name.Identifier}");
- throw new DocumentException();
- }
- else
- {
- _entropy.DoesTestStepsMetadataExist = true;
+ if (properties.Count != 2)
+ {
+ _errors.ValidationError($"Unable to find TestStepsMetadata property for TestCase {control.Name.Identifier}");
+ throw new DocumentException();
+ }
+
+ // No steps, so we can exit this method early
+ return;
}
+
properties.Remove(_metadataPropName);
var metadataJsonString = metadataProperty.Expression.Expression.UnEscapePAString();
var testStepsMetadata = JsonConvert.DeserializeObject>(metadataJsonString);
@@ -148,7 +148,6 @@ public void AfterRead(BlockNode control)
public void BeforeWrite(BlockNode control)
{
var testStepsMetadata = new List();
- var doesTestStepsMetadataExist = _entropy.DoesTestStepsMetadataExist ?? true;
foreach (var child in control.Children)
{
@@ -209,24 +208,22 @@ public void BeforeWrite(BlockNode control)
}
}
- if (doesTestStepsMetadataExist)
+ testStepsMetadata.Add(new TestStepsMetadataJson()
{
- testStepsMetadata.Add(new TestStepsMetadataJson()
- {
- Description = descriptionProp.Expression.Expression.UnEscapePAString(),
- Rule = propName,
- ScreenId = screenId
- });
+ Description = descriptionProp.Expression.Expression.UnEscapePAString(),
+ Rule = propName,
+ ScreenId = screenId
+ });
- control.Properties.Add(new PropertyNode()
- {
- Expression = valueProp.Expression,
- Identifier = propName
- });
- }
+ control.Properties.Add(new PropertyNode()
+ {
+ Expression = valueProp.Expression,
+ Identifier = propName
+ });
}
- if (doesTestStepsMetadataExist)
+ if (testStepsMetadata.Count > 0
+ || !_entropy.AppTestsMissingStepsMetadata.Contains(control.Name.Identifier))
{
/* When Canvas creates the TestStepsMetadata value, it does so using Newtonsoft, creating a JArray of JObjects and calling
* the ToString method on that JArray with no special formatting. This skips escaping on a number of Unicode characters
@@ -234,7 +231,7 @@ public void BeforeWrite(BlockNode control)
* certain Unicode characters to be escaped in all cases. As such, we use Newtonsoft for TestStepsMetadata to match the
* behavior in Canvas and prevent roundtrip errors. The appropriate encoding will ultimately happen when the full document
* is serialized to JSON during the creation of the msapp, and will be consistent with how Canvas serializes an msapp.
- *
+ *
* See: https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-character-encoding#global-block-list
*/
var testStepMetadataStr = JsonConvert.SerializeObject(testStepsMetadata, Formatting.None);
diff --git a/src/PAModelTests/Apps/EmptyTestCase.msapp b/src/PAModelTests/Apps/EmptyTestCase.msapp
index e66a7e99..86de5f92 100644
Binary files a/src/PAModelTests/Apps/EmptyTestCase.msapp and b/src/PAModelTests/Apps/EmptyTestCase.msapp differ
diff --git a/src/PAModelTests/EntropyTests.cs b/src/PAModelTests/EntropyTests.cs
index 28448bc7..a9da9ad3 100644
--- a/src/PAModelTests/EntropyTests.cs
+++ b/src/PAModelTests/EntropyTests.cs
@@ -148,7 +148,7 @@ public void TestPCFControlWillFallBackToControlTemplate(string appName)
using (var tempFile = new TempFile())
{
MsAppSerializer.SaveAsMsApp(msapp, tempFile.FullPath, new ErrorContainer());
- Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath, Console.Out));
+ Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath));
}
}
}
diff --git a/src/PAModelTests/ErrorTests.cs b/src/PAModelTests/ErrorTests.cs
index 8a193aa4..20e46fbf 100644
--- a/src/PAModelTests/ErrorTests.cs
+++ b/src/PAModelTests/ErrorTests.cs
@@ -15,6 +15,8 @@ public class ErrorTests
public static string PathMissingDir = Path.Combine(Environment.CurrentDirectory, "MissingDirectory");
public static int counter;
+ public TestContext TestContext { get; set; }
+
[TestMethod]
public void OpenCorruptedMsApp()
{
@@ -88,11 +90,13 @@ public void TestJSONValueChanged(string file1, string file2, string file3)
var jsonDictionary2 = MsAppTest.FlattenJson(jsonString2);
// IsMismatched on mismatched files
- MsAppTest.CheckPropertyChangedRemoved(jsonDictionary1, jsonDictionary2, errorContainer, "");
- MsAppTest.CheckPropertyAdded(jsonDictionary1, jsonDictionary2, errorContainer, "");
+ MsAppTest.CheckPropertyChangedRemoved(file1, jsonDictionary1, jsonDictionary2, errorContainer);
+ MsAppTest.CheckPropertyAdded(file1, jsonDictionary1, jsonDictionary2, errorContainer);
// Confirm that the unit tests have the expected output
- File.ReadAllText(path3).Should().Be(errorContainer.ToString());
+ var errorContainerString = errorContainer.ToString();
+ TestContext.WriteLine(errorContainerString);
+ errorContainerString.Should().Be(File.ReadAllText(path3));
}
[TestMethod]
@@ -105,7 +109,7 @@ public void CompareChecksumImageNotReadAsJSONTest(string app1, string app2)
// When there's a file content mismatch on non-JSON files,
// we must throw an error and not use JSON to compare non JSON-files
- var exception = Assert.ThrowsExactly(() => MsAppTest.Compare(pathToZip1, pathToZip2, Console.Out));
+ var exception = Assert.ThrowsExactly(() => MsAppTest.Compare(pathToZip1, pathToZip2));
exception.Message.Should().Be("Mismatch detected in non-Json properties: Assets\\Images\\1556681b-11bd-4d72-9b17-4f884fb4b465.png");
}
}
diff --git a/src/PAModelTests/RoundtripTests.cs b/src/PAModelTests/RoundtripTests.cs
index 297f0df7..066580a0 100644
--- a/src/PAModelTests/RoundtripTests.cs
+++ b/src/PAModelTests/RoundtripTests.cs
@@ -11,16 +11,29 @@ namespace PAModelTests;
[TestClass]
public class RoundtripTests
{
- private static IEnumerable