From ed315ca6bf7b60bf71b8650be9e53dffa7b9180c Mon Sep 17 00:00:00 2001 From: Joe Mayo Date: Fri, 15 May 2026 14:20:26 -0700 Subject: [PATCH 1/5] update gitignore for C# Dev Kit cache files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From 03cbf1932cff9d7c36d38ff88cc986078da6fccb Mon Sep 17 00:00:00 2001 From: Joe Mayo Date: Tue, 19 May 2026 15:39:52 -0700 Subject: [PATCH 2/5] Improve MsAppTest comparison recursive logic - treat 'Rule' objects with simpler paths to allow for better error diagnostics - Remove unused Log parameters from MsAppTest methods - Improve diagnostic error syntax to match MSBuild formats, and include file names - Fix Sopa Program to resolve input file paths - modernize certain coding constructs in modified classes. e.g. 'using' statements --- src/PAModel/CanvasDocument.cs | 27 +- src/PAModel/Checksum/ChecksumMaker.cs | 21 +- src/PAModel/MsAppTest.cs | 354 ++++++++---------- src/PAModel/PAConvert/Error.cs | 18 +- src/PAModel/PAConvert/ErrorCode.cs | 12 +- .../PAConvert/Parser/SourceLocation.cs | 33 +- .../SourceTransforms/AppTestTransform.cs | 6 +- src/PAModelTests/EntropyTests.cs | 2 +- src/PAModelTests/ErrorTests.cs | 12 +- src/PAModelTests/TemplateStoreTests.cs | 4 +- .../TestData/complexAddedOutput.txt | 8 +- .../TestData/complexChangedOutput.txt | 10 +- .../TestData/complexRemovedOutput.txt | 8 +- .../TestData/emptyNestedArrayOutput.txt | 2 +- .../TestData/simpleAddedOutput.txt | 8 +- .../TestData/simpleArrayOutput.txt | 2 +- .../TestData/simpleChangedOutput.txt | 18 +- .../TestData/simpleRemovedOutput.txt | 8 +- src/PAModelTests/YamlTest.cs | 6 +- src/PASopa/Program.cs | 13 +- 20 files changed, 278 insertions(+), 294 deletions(-) diff --git a/src/PAModel/CanvasDocument.cs b/src/PAModel/CanvasDocument.cs index 5b196cd9..28def395 100644 --- a/src/PAModel/CanvasDocument.cs +++ b/src/PAModel/CanvasDocument.cs @@ -178,32 +178,29 @@ 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; - } + var ok = MsAppTest.Compare(verifyOriginalPath, temp.FullPath, errors2); + if (!ok) + { + errors2.PostUnpackValidationFailed(); + return errors2; } } 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/MsAppTest.cs b/src/PAModel/MsAppTest.cs index 1d5a0181..83fbfcf0 100644 --- a/src/PAModel/MsAppTest.cs +++ b/src/PAModel/MsAppTest.cs @@ -14,15 +14,13 @@ 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 = "")] @@ -70,13 +68,13 @@ 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()) { @@ -90,59 +88,52 @@ private static bool HasNoDeltas(CanvasDocument doc1, CanvasDocument doc2, bool s // Save and verify checksums. - 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); + using var temp1 = new TempFile(); + using var temp2 = new TempFile(); + doc1.SaveToMsApp(temp1.FullPath); + doc2.SaveToMsApp(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) { @@ -152,8 +143,6 @@ public static bool StressTest(string pathToMsApp) { var outFile = temp1.FullPath; - var log = TextWriter.Null; - // MsApp --> Model CanvasDocument msapp; var errors = new ErrorContainer(); @@ -163,7 +152,6 @@ public static bool StressTest(string pathToMsApp) { msapp = MsAppSerializer.Load(stream, errors); } - errors.Write(log); errors.ThrowOnErrors(); // We can still get warnings here. Commonly: @@ -179,17 +167,15 @@ public static bool StressTest(string pathToMsApp) // Model --> MsApp errors = msapp.SaveToMsApp(outFile); errors.ThrowOnErrors(); - var ok = Compare(pathToMsApp, outFile, log); + var ok = Compare(pathToMsApp, outFile); if (!ok) { return false; } // Model --> Source - using (var tempDir = new TempDir()) - { - var outSrcDir = tempDir.Dir; - errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp); - errors.ThrowOnErrors(); - } + using var tempDir = new TempDir(); + var outSrcDir = tempDir.Dir; + errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp); + errors.ThrowOnErrors(); } // end using if (!TestClone(pathToMsApp)) @@ -211,18 +197,16 @@ public static bool StressTest(string pathToMsApp) 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 +214,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,184 +236,154 @@ 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) { - var newContents = ChecksumMaker.ChecksumFile(entry.FullName, entry.ToBytes()); - if (newContents == null) + continue; + } + + // Do easy diffs + var entryFullName = entry.FullName; + if (first) + { + 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! + Console.WriteLine("FAIL: 2nd has added file: " + 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); } } 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..dc2264c9 100644 --- a/src/PAModel/PAConvert/ErrorCode.cs +++ b/src/PAModel/PAConvert/ErrorCode.cs @@ -168,19 +168,19 @@ 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 UnsupportedError(this ErrorContainer errors, string 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..35c8a9e8 100644 --- a/src/PAModel/SourceTransforms/AppTestTransform.cs +++ b/src/PAModel/SourceTransforms/AppTestTransform.cs @@ -56,9 +56,6 @@ 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) @@ -71,6 +68,7 @@ public void AfterRead(BlockNode control) { _entropy.DoesTestStepsMetadataExist = true; } + properties.Remove(_metadataPropName); var metadataJsonString = metadataProperty.Expression.Expression.UnEscapePAString(); var testStepsMetadata = JsonConvert.DeserializeObject>(metadataJsonString); @@ -148,7 +146,7 @@ public void AfterRead(BlockNode control) public void BeforeWrite(BlockNode control) { var testStepsMetadata = new List(); - var doesTestStepsMetadataExist = _entropy.DoesTestStepsMetadataExist ?? true; + var doesTestStepsMetadataExist = _entropy.DoesTestStepsMetadataExist ?? false; foreach (var child in control.Children) { 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/TemplateStoreTests.cs b/src/PAModelTests/TemplateStoreTests.cs index 5e32d90b..25a6c7be 100644 --- a/src/PAModelTests/TemplateStoreTests.cs +++ b/src/PAModelTests/TemplateStoreTests.cs @@ -41,7 +41,7 @@ public void TestHostControlInstancesWithHostType(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)); } } @@ -68,7 +68,7 @@ public void TestModernControlWithDynamicTemplate(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/TestData/complexAddedOutput.txt b/src/PAModelTests/TestData/complexAddedOutput.txt index 4bfcaf54..ed369871 100644 --- a/src/PAModelTests/TestData/complexAddedOutput.txt +++ b/src/PAModelTests/TestData/complexAddedOutput.txt @@ -1,5 +1,5 @@ -Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Primary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Secondary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Primary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Secondary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Primary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Secondary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Primary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Secondary 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/complexChangedOutput.txt b/src/PAModelTests/TestData/complexChangedOutput.txt index 293ed884..ad1b08f1 100644 --- a/src/PAModelTests/TestData/complexChangedOutput.txt +++ b/src/PAModelTests/TestData/complexChangedOutput.txt @@ -1,6 +1,6 @@ -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].2-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].3-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].2-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].3-D -Error PA3013: Property Value Changed: Noun.Adjectives[1].Colors[0].Primary +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].2-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].3-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].2-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].3-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[1].Colors[0].Primary 5 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/complexRemovedOutput.txt b/src/PAModelTests/TestData/complexRemovedOutput.txt index 259b073f..646168a1 100644 --- a/src/PAModelTests/TestData/complexRemovedOutput.txt +++ b/src/PAModelTests/TestData/complexRemovedOutput.txt @@ -1,5 +1,5 @@ -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Primary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Secondary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Primary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Secondary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Primary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Secondary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Primary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Secondary 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/emptyNestedArrayOutput.txt b/src/PAModelTests/TestData/emptyNestedArrayOutput.txt index 9ede55e7..9dfb440b 100644 --- a/src/PAModelTests/TestData/emptyNestedArrayOutput.txt +++ b/src/PAModelTests/TestData/emptyNestedArrayOutput.txt @@ -1,2 +1,2 @@ -Error PA3013: Property Value Changed: Adjectives +emptyNestedArray1.json: Error PA3013: Property Value Changed: Adjectives 1 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleAddedOutput.txt b/src/PAModelTests/TestData/simpleAddedOutput.txt index 376ba8ef..583e260e 100644 --- a/src/PAModelTests/TestData/simpleAddedOutput.txt +++ b/src/PAModelTests/TestData/simpleAddedOutput.txt @@ -1,5 +1,5 @@ -Error PA3014: Property Added: Words.Adverb -Error PA3014: Property Added: Numbers.Single -Error PA3014: Property Added: Numbers.Double -Error PA3014: Property Added: Numbers.Triple +simpleAdded1.json: Error PA3014: Property Added: Words.Adverb +simpleAdded1.json: Error PA3014: Property Added: Numbers.Single +simpleAdded1.json: Error PA3014: Property Added: Numbers.Double +simpleAdded1.json: Error PA3014: Property Added: Numbers.Triple 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleArrayOutput.txt b/src/PAModelTests/TestData/simpleArrayOutput.txt index 9ede55e7..b9ac0b37 100644 --- a/src/PAModelTests/TestData/simpleArrayOutput.txt +++ b/src/PAModelTests/TestData/simpleArrayOutput.txt @@ -1,2 +1,2 @@ -Error PA3013: Property Value Changed: Adjectives +simpleArray1.json: Error PA3013: Property Value Changed: Adjectives 1 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleChangedOutput.txt b/src/PAModelTests/TestData/simpleChangedOutput.txt index 81802026..448313ae 100644 --- a/src/PAModelTests/TestData/simpleChangedOutput.txt +++ b/src/PAModelTests/TestData/simpleChangedOutput.txt @@ -1,10 +1,10 @@ -Error PA3015: Property Removed: Words.Noun -Error PA3015: Property Removed: Words.Adjective -Error PA3015: Property Removed: Words.Verb -Error PA3015: Property Removed: Words.Adverb -Error PA3013: Property Value Changed: Numbers.Double -Error PA3014: Property Added: Things.Noun -Error PA3014: Property Added: Things.Adjective -Error PA3014: Property Added: Things.Verb -Error PA3014: Property Added: Things.Adverb +simpleChanged1.json: Error PA3015: Property Removed: Words.Noun +simpleChanged1.json: Error PA3015: Property Removed: Words.Adjective +simpleChanged1.json: Error PA3015: Property Removed: Words.Verb +simpleChanged1.json: Error PA3015: Property Removed: Words.Adverb +simpleChanged1.json: Error PA3013: Property Value Changed: Numbers.Double +simpleChanged1.json: Error PA3014: Property Added: Things.Noun +simpleChanged1.json: Error PA3014: Property Added: Things.Adjective +simpleChanged1.json: Error PA3014: Property Added: Things.Verb +simpleChanged1.json: Error PA3014: Property Added: Things.Adverb 9 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleRemovedOutput.txt b/src/PAModelTests/TestData/simpleRemovedOutput.txt index 1e3cb2e1..a10619b5 100644 --- a/src/PAModelTests/TestData/simpleRemovedOutput.txt +++ b/src/PAModelTests/TestData/simpleRemovedOutput.txt @@ -1,5 +1,5 @@ -Error PA3015: Property Removed: Words.Adverb -Error PA3015: Property Removed: Numbers.Single -Error PA3015: Property Removed: Numbers.Double -Error PA3015: Property Removed: Numbers.Triple +simpleRemoved1.json: Error PA3015: Property Removed: Words.Adverb +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Single +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Double +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Triple 4 errors, 0 warnings. diff --git a/src/PAModelTests/YamlTest.cs b/src/PAModelTests/YamlTest.cs index c5051618..9cbddfc7 100644 --- a/src/PAModelTests/YamlTest.cs +++ b/src/PAModelTests/YamlTest.cs @@ -240,10 +240,10 @@ public void ReadBasicSpans() using var sr = new StringReader(text); using var y = new YamlLexer(sr, "test.yaml"); - AssertLex("Obj1:", y, "test.yaml:1,1-1,6"); - AssertLex("P1=456", y, "test.yaml:2,4-2,12"); + AssertLex("Obj1:", y, "test.yaml(1,1,1,6)"); + AssertLex("P1=456", y, "test.yaml(2,4,2,12)"); AssertLexEndObj(y); - AssertLex("Obj2:", y, "test.yaml:4,1-4,6"); + AssertLex("Obj2:", y, "test.yaml(4,1,4,6)"); AssertLexEndObj(y); AssertLexEndFile(y); } diff --git a/src/PASopa/Program.cs b/src/PASopa/Program.cs index ad330cb8..cf731e45 100644 --- a/src/PASopa/Program.cs +++ b/src/PASopa/Program.cs @@ -116,16 +116,9 @@ private static void Main(string[] args) throw new InvalidOperationException("must be path to .msapp file"); } - string outDir; - if (args.Length == 2) - { - outDir = msAppPath.Substring(0, msAppPath.Length - 6) + "_src"; // chop off ".msapp"; - } - else - { - outDir = args[2]; - } - + string outDir = args.Length >= 3 + ? Path.GetFullPath(args[2]) + : Path.Combine(Path.GetDirectoryName(msAppPath), Path.GetFileNameWithoutExtension(msAppPath) + "_src"); Console.WriteLine($"Unpack: {msAppPath} --> {outDir} "); (var msApp, var errors) = TryOperation(() => CanvasDocument.LoadFromMsapp(msAppPath)); From 6660020a79a363878e1a2411afc94b48cc726ecc Mon Sep 17 00:00:00 2001 From: Joe Mayo Date: Tue, 19 May 2026 16:56:39 -0700 Subject: [PATCH 3/5] Update roundtrip tests for EmptyTestCase.msapp --- src/PAModelTests/Apps/EmptyTestCase.msapp | Bin 14813 -> 18140 bytes src/PAModelTests/RoundtripTests.cs | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/PAModelTests/Apps/EmptyTestCase.msapp b/src/PAModelTests/Apps/EmptyTestCase.msapp index e66a7e9963a3b2634ecf7d70b6bbe584e3fc8c6f..86de5f92d1ad5f6623b284f145392937efe9cdb8 100644 GIT binary patch literal 18140 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 ABme*a 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 TestAppFilePaths => Directory.GetFiles(Path.Combine(Environment.CurrentDirectory, "Apps"), "*.msapp").Select(p => new[] { p }); + private static IEnumerable TestAppFilePaths + { + get + { + var appsDirectory = new DirectoryInfo("Apps"); + foreach (var file in appsDirectory.EnumerateFiles("*.msapp", SearchOption.AllDirectories)) + { + var testAppRelativePath = file.FullName.Substring(Environment.CurrentDirectory.Length + 1); + + yield return new object[] { testAppRelativePath }; + } + } + } // Apps live in the "Apps" folder, and should have a build action of "Copy to output" [TestMethod] [DynamicData(nameof(TestAppFilePaths))] public void StressTestApps(string msappPath) { - MsAppTest.StressTest(msappPath).Should().BeTrue(); + var msappFullPath = Path.GetFullPath(msappPath); + MsAppTest.StressTest(msappFullPath).Should().BeTrue(); // If this fails, to debug it, rerun and set a breakpoint in DebugChecksum(). - MsAppTest.TestClone(msappPath).Should().BeTrue(); + MsAppTest.TestClone(msappFullPath).Should().BeTrue(); } } From c4e810a2d9e49383a34e29a3c91b965907dfc173 Mon Sep 17 00:00:00 2001 From: Joe Mayo Date: Tue, 19 May 2026 17:51:29 -0700 Subject: [PATCH 4/5] - remove references to `Console` from within the library - Emit error when detecting extra archive entry --- src/PAModel/CanvasDocument.cs | 47 +++++++------ src/PAModel/MsAppTest.cs | 107 ++++++++++++----------------- src/PAModel/PAConvert/ErrorCode.cs | 8 +++ 3 files changed, 76 insertions(+), 86 deletions(-) diff --git a/src/PAModel/CanvasDocument.cs b/src/PAModel/CanvasDocument.cs index 28def395..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. @@ -196,8 +196,7 @@ public ErrorContainer SaveToSources(string pathToSourceDirectory, string verifyO return errors2; } - var ok = MsAppTest.Compare(verifyOriginalPath, temp.FullPath, errors2); - if (!ok) + if (!MsAppTest.Compare(verifyOriginalPath, temp.FullPath, errors2)) { errors2.PostUnpackValidationFailed(); return errors2; @@ -215,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 @@ -317,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 @@ -373,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); @@ -430,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); @@ -463,15 +462,16 @@ private void AddComponentDefaults(BlockNode topParent, Dictionary GetImportedComponents() { var set = new HashSet(); @@ -676,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; @@ -690,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/MsAppTest.cs b/src/PAModel/MsAppTest.cs index 83fbfcf0..1c06bb8c 100644 --- a/src/PAModel/MsAppTest.cs +++ b/src/PAModel/MsAppTest.cs @@ -26,27 +26,19 @@ public static bool Compare(CanvasDocument doc1, CanvasDocument doc2) [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) @@ -78,10 +70,6 @@ private static bool HasNoDeltas(CanvasDocument doc1, CanvasDocument doc2, bool s if (ourDeltas.Any()) { - foreach (var diff in ourDeltas) - { - Console.WriteLine($" {diff.GetType().Name}"); - } // Error! app shouldn't have any diffs with itself. return false; } @@ -137,60 +125,55 @@ public static CanvasDocument RemoveEntropy(string pathToMsApp) [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 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.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); - 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; } @@ -262,7 +245,7 @@ private static void CompareChecksums(string pathToZip, Dictionary Date: Tue, 19 May 2026 16:57:14 -0700 Subject: [PATCH 5/5] Add Entropy.AppTestsMissingStepsMetadata --- src/PAModel/Entropy.cs | 6 +-- .../SourceTransforms/AppTestTransform.cs | 49 +++++++++---------- 2 files changed, 27 insertions(+), 28 deletions(-) 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/SourceTransforms/AppTestTransform.cs b/src/PAModel/SourceTransforms/AppTestTransform.cs index 35c8a9e8..4c3a46f4 100644 --- a/src/PAModel/SourceTransforms/AppTestTransform.cs +++ b/src/PAModel/SourceTransforms/AppTestTransform.cs @@ -57,16 +57,18 @@ public void AfterRead(BlockNode control) if (!properties.TryGetValue(_metadataPropName, out var metadataProperty)) { // 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); @@ -146,7 +148,6 @@ public void AfterRead(BlockNode control) public void BeforeWrite(BlockNode control) { var testStepsMetadata = new List(); - var doesTestStepsMetadataExist = _entropy.DoesTestStepsMetadataExist ?? false; foreach (var child in control.Children) { @@ -207,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 @@ -232,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);