From 7b59181dc1e3b53aac1591fc6678602248a34eb1 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 6 May 2026 00:49:58 +0530 Subject: [PATCH 1/9] fix: merge .percy.yml config options with snapshot options for serializeDOM Config options from .percy.yml (like widths, minHeight, enableJavaScript, etc.) were not being passed to PercyDOM.serialize(). Only per-snapshot options were used. Now merges both, with per-snapshot options taking priority. Co-Authored-By: Claude Opus 4.6 --- Percy/Percy.cs | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/Percy/Percy.cs b/Percy/Percy.cs index 98f1731..28e6f42 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -713,6 +713,40 @@ public static List> CaptureResponsiveDom(WebDriver dr return domSnapshots; } + private static Dictionary MergeSnapshotOptions(Dictionary? options) + { + var merged = new Dictionary(); + if (cliConfig != null) + { + try + { + JsonElement config = (JsonElement)cliConfig; + if (config.TryGetProperty("snapshot", out JsonElement snapshotElement) && + snapshotElement.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty prop in snapshotElement.EnumerateObject()) + { + merged[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => prop.Value.TryGetInt32(out int intVal) ? intVal : (object)prop.Value.GetDouble(), + JsonValueKind.String => prop.Value.GetString(), + _ => prop.Value + }; + } + } + } + catch (Exception) { } + } + if (options != null) + { + foreach (var kvp in options) + merged[kvp.Key] = kvp.Value; + } + return merged; + } + private static bool isResponsiveSnapshotCapture(Dictionary? options) { if (cliConfig == null) return false; @@ -763,14 +797,16 @@ public class Options : Dictionary {} if (_dom == null) _dom = GetPercyDOM(); + // Merge .percy.yml config options with snapshot options (snapshot options take priority) + var mergedOptions = MergeSnapshotOptions(options); + var cookies = driver.Manage().Cookies.AllCookies; - string opts = JsonSerializer.Serialize(options); dynamic domSnapshot = null; - if (isResponsiveSnapshotCapture(options)) { - domSnapshot = CaptureResponsiveDom(driver, cookies, options); + if (isResponsiveSnapshotCapture(mergedOptions)) { + domSnapshot = CaptureResponsiveDom(driver, cookies, mergedOptions); } else { - domSnapshot = getSerializedDom(driver, cookies, options, _dom); + domSnapshot = getSerializedDom(driver, cookies, mergedOptions, _dom); } Options snapshotOptions = new Options { From 6dabd1f2ca2d0e5d2fa1c86116eae4cf1568bfe9 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:15:04 +0530 Subject: [PATCH 2/9] fix: convert config JSON array (widths) to typed list in merge to avoid InvalidCastException Ref: PER-8053 --- Percy/Percy.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Percy/Percy.cs b/Percy/Percy.cs index 89c1bfd..f9d6bd3 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -851,6 +851,7 @@ private static Dictionary MergeSnapshotOptions(Dictionary false, JsonValueKind.Number => prop.Value.TryGetInt32(out int intVal) ? intVal : (object)prop.Value.GetDouble(), JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Array => ConvertJsonArray(prop.Value), _ => prop.Value }; } @@ -866,6 +867,24 @@ private static Dictionary MergeSnapshotOptions(Dictionary 0 && items.All(item => item.ValueKind == JsonValueKind.Number && item.TryGetInt32(out _))) + { + return items.Select(item => item.GetInt32()).ToList(); + } + + return items.Select(item => item.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => item.TryGetInt32(out int intVal) ? intVal : (object)item.GetDouble(), + JsonValueKind.String => item.GetString(), + _ => (object)item + }).ToList(); + } + private static bool isResponsiveSnapshotCapture(Dictionary? options) { if (cliConfig == null) return false; From 4f2e554ac8769ab46d64712cfd88c79b3075e222 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:35:44 +0530 Subject: [PATCH 3/9] test: cover .percy.yml config <-> per-snapshot merge precedence Ref: PER-8053 --- Percy.Test/Percy.Test.cs | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Percy.Test/Percy.Test.cs b/Percy.Test/Percy.Test.cs index 211e6b2..8e88d73 100644 --- a/Percy.Test/Percy.Test.cs +++ b/Percy.Test/Percy.Test.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Reflection; using System.Threading.Tasks; using System.Collections.Generic; using System.Text; @@ -384,6 +385,68 @@ public void PostsSnapshotWithReadinessDisabled() Assert.Contains("Received snapshot: readiness-disabled", logs); } } + + // Verifies the .percy.yml config (cliConfig.snapshot) <-> per-snapshot + // option merge that Percy.Snapshot performs (MergeSnapshotOptions) before + // handing the result to PercyDOM.serialize. Two precedence rules: + // + // - config seeds the merge: a config-only key (enableJavaScript) is + // inherited into the merged options, AND + // - per-call overlays config: a key present in BOTH config and the + // per-snapshot call (percyCSS) is won by the per-snapshot value. + // + // MergeSnapshotOptions is the single source of truth for what reaches + // PercyDOM.serialize, and Selenium 4's WebDriver is not mockable (no public + // parameterless ctor, the ctor starts a real session, and ExecuteScript / + // Manage / Url are non-virtual). So we exercise the merge directly through + // its private static surface via reflection — fully deterministic, no + // browser and no CLI server required. + public class MergePrecedenceTests + { + private static Dictionary InvokeMerge( + JsonElement cliConfig, Dictionary? perSnapshotOptions) + { + // Seed the SDK's cliConfig (internal setter, visible to Percy.Test). + Percy.ResetInternalCaches(); + Percy.setCliConfig(cliConfig); + + MethodInfo merge = typeof(Percy).GetMethod( + "MergeSnapshotOptions", BindingFlags.NonPublic | BindingFlags.Static)!; + object? result = merge.Invoke(null, new object?[] { perSnapshotOptions }); + return (Dictionary)result!; + } + + [Fact] + public void PerSnapshotOptionWinsOverConfigDuringMerge() + { + try + { + // .percy.yml config: enableJavaScript is config-only; percyCSS is + // set here AND overridden per-snapshot to exercise the conflict. + JsonElement cliConfig = JsonSerializer.Deserialize( + "{\"snapshot\":{\"enableJavaScript\":true,\"percyCSS\":\"FROM_CONFIG\"}}"); + + var perSnapshotOptions = new Dictionary + { + { "percyCSS", "FROM_CALL" } + }; + + Dictionary merged = InvokeMerge(cliConfig, perSnapshotOptions); + + // Config seeds the merge: the config-only key is inherited. + Assert.True(merged.ContainsKey("enableJavaScript")); + Assert.Equal(true, merged["enableJavaScript"]); + + // Per-call overlays config: the per-snapshot value wins on conflict. + Assert.Equal("FROM_CALL", merged["percyCSS"]); + } + finally + { + Percy.ResetInternalCaches(); + } + } + } + public class RegionTests { [Fact] From 17070dd78b92dede79a0f54598bd9069d6c464c6 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 00:54:19 +0530 Subject: [PATCH 4/9] feat: deep-merge .percy.yml config with per-snapshot options Ref: PER-8053 --- Percy.Test/Percy.Test.cs | 38 +++++++++++++++++++ Percy/Percy.cs | 82 ++++++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/Percy.Test/Percy.Test.cs b/Percy.Test/Percy.Test.cs index 8e88d73..9e3028e 100644 --- a/Percy.Test/Percy.Test.cs +++ b/Percy.Test/Percy.Test.cs @@ -445,6 +445,44 @@ public void PerSnapshotOptionWinsOverConfigDuringMerge() Percy.ResetInternalCaches(); } } + + [Fact] + public void NestedConfigObjectIsDeepMergedWithPerSnapshotOptions() + { + try + { + // .percy.yml config: nested discovery object with two leaves. + JsonElement cliConfig = JsonSerializer.Deserialize( + "{\"snapshot\":{\"discovery\":{\"networkIdleTimeout\":50,\"disableCache\":false}}}"); + + // Per-snapshot call overrides only one leaf of the nested object. + var perSnapshotOptions = new Dictionary + { + { + "discovery", new Dictionary + { + { "disableCache", true } + } + } + }; + + Dictionary merged = InvokeMerge(cliConfig, perSnapshotOptions); + + // Nested object is deep-merged, not replaced wholesale. + Assert.True(merged.ContainsKey("discovery")); + var discovery = Assert.IsType>(merged["discovery"]); + + // Config-only leaf is preserved. + Assert.Equal(50, discovery["networkIdleTimeout"]); + + // Per-call leaf wins at the leaf level. + Assert.Equal(true, discovery["disableCache"]); + } + finally + { + Percy.ResetInternalCaches(); + } + } } public class RegionTests diff --git a/Percy/Percy.cs b/Percy/Percy.cs index f9d6bd3..e385b22 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -845,15 +845,12 @@ private static Dictionary MergeSnapshotOptions(Dictionary true, - JsonValueKind.False => false, - JsonValueKind.Number => prop.Value.TryGetInt32(out int intVal) ? intVal : (object)prop.Value.GetDouble(), - JsonValueKind.String => prop.Value.GetString(), - JsonValueKind.Array => ConvertJsonArray(prop.Value), - _ => prop.Value - }; + // Recursively convert config values so nested JSON + // objects become Dictionary and can be + // deep-merged with per-call options below. + var converted = JsonElementToObjectDeep(prop.Value); + if (converted != null) + merged[prop.Name] = converted; } } } @@ -861,12 +858,75 @@ private static Dictionary MergeSnapshotOptions(Dictionary Dictionary (recursive), + // Array -> List (recursive), + // primitives -> bool / int / double / string. + private static object? JsonElementToObjectDeep(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + var dict = new Dictionary(); + foreach (JsonProperty prop in el.EnumerateObject()) + { + var converted = JsonElementToObjectDeep(prop.Value); + if (converted != null) + dict[prop.Name] = converted; + } + return dict; + case JsonValueKind.Array: + var list = new List(); + foreach (JsonElement item in el.EnumerateArray()) + { + var converted = JsonElementToObjectDeep(item); + if (converted != null) + list.Add(converted); + } + return list; + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Number: + return el.TryGetInt32(out int intVal) ? (object)intVal : el.GetDouble(); + case JsonValueKind.String: + return el.GetString(); + default: + return null; + } + } + + // Deep-merges override into a copy of baseDict: when a key exists in both + // and both values are Dictionary, recurse; otherwise the + // override value wins (arrays and scalars replace). + private static Dictionary DeepMerge( + Dictionary baseDict, Dictionary overrideDict) + { + var result = new Dictionary(baseDict); + foreach (var kvp in overrideDict) + { + if (result.TryGetValue(kvp.Key, out var existing) && + existing is Dictionary existingDict && + kvp.Value is Dictionary overrideNested) + { + result[kvp.Key] = DeepMerge(existingDict, overrideNested); + } + else + { + result[kvp.Key] = kvp.Value; + } + } + return result; + } + private static object ConvertJsonArray(JsonElement array) { var items = array.EnumerateArray().ToList(); From 042d9c8adfe4600987a8caa2dd7fe7a113f3a2fe Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 01:33:31 +0530 Subject: [PATCH 5/9] fix: keep all-int config arrays (e.g. widths) as List in deep-merge conversion JsonElementToObjectDeep converted JSON arrays to List, which broke responsive widths (selenium-dotnet hard-casts to List; playwright-dotnet's IEnumerable check missed List). Convert all-integer arrays to List so config-sourced widths flow through responsive capture. Ref: PER-8053 --- Percy/Percy.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Percy/Percy.cs b/Percy/Percy.cs index e385b22..626c113 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -883,8 +883,13 @@ private static Dictionary MergeSnapshotOptions(Dictionary so + // downstream consumers like CaptureResponsiveDom can use them directly. + if (items.Count > 0 && items.All(i => i.ValueKind == JsonValueKind.Number && i.TryGetInt32(out _))) + return items.Select(i => i.GetInt32()).ToList(); var list = new List(); - foreach (JsonElement item in el.EnumerateArray()) + foreach (JsonElement item in items) { var converted = JsonElementToObjectDeep(item); if (converted != null) From 65c55e10b0ee37a23262651fe91f5fb817d51d17 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Thu, 9 Jul 2026 16:57:25 +0530 Subject: [PATCH 6/9] test: cover JsonElementToObjectDeep + MergeSnapshotOptions for 100% line gate The merged 100% line-coverage gate flagged the PER-8053 config-merge helpers (JsonElementToObjectDeep / MergeSnapshotOptions) as partially uncovered (98.41%). Add tests exercising every JsonElementToObjectDeep ValueKind branch (object, all-int array, mixed array, true, false, int, double, string, null) and the MergeSnapshotOptions merge (cli-config + per-call override, and the null-options/no-config path). Co-Authored-By: Claude Opus 4.8 --- Percy.Test/PercyLogic.Test.cs | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Percy.Test/PercyLogic.Test.cs b/Percy.Test/PercyLogic.Test.cs index 3a82816..2db39ac 100644 --- a/Percy.Test/PercyLogic.Test.cs +++ b/Percy.Test/PercyLogic.Test.cs @@ -188,6 +188,48 @@ public void JsonElementToObject_MapsComplexKindsToRawText() Assert.Contains("\"a\"", obj); } + // ===== JsonElementToObjectDeep / MergeSnapshotOptions (PER-8053) ======= + + [Fact] + public void JsonElementToObjectDeep_CoversAllValueKinds() + { + // Recursive converter behind config-merge; exercise every ValueKind branch. + Assert.IsType>( + InvokePrivate("JsonElementToObjectDeep", ParseJson("{\"a\":1}"))!); + // all-integer arrays collapse to List (e.g. widths) + Assert.IsType>( + InvokePrivate("JsonElementToObjectDeep", ParseJson("[375,1280]"))!); + // mixed arrays stay List + Assert.IsType>( + InvokePrivate("JsonElementToObjectDeep", ParseJson("[1,\"x\"]"))!); + Assert.Equal(true, InvokePrivate("JsonElementToObjectDeep", ParseJson("true"))); + Assert.Equal(false, InvokePrivate("JsonElementToObjectDeep", ParseJson("false"))); + Assert.Equal(7, InvokePrivate("JsonElementToObjectDeep", ParseJson("7"))); + Assert.Equal(1.5, InvokePrivate("JsonElementToObjectDeep", ParseJson("1.5"))); + Assert.Equal("s", InvokePrivate("JsonElementToObjectDeep", ParseJson("\"s\""))); + Assert.Null(InvokePrivate("JsonElementToObjectDeep", ParseJson("null"))); + } + + [Fact] + public void MergeSnapshotOptions_MergesCliConfigWithPerCallOptions() + { + // Global .percy.yml snapshot config merged with per-call options; per-call wins. + SetCliConfigJson("{\"snapshot\":{\"enableJavaScript\":true,\"widths\":[375,1280],\"percyCSS\":\"FROM_CONFIG\"}}"); + var options = new Dictionary { { "percyCSS", "FROM_CALL" } }; + var merged = (Dictionary)InvokePrivate("MergeSnapshotOptions", options)!; + Assert.True((bool)merged["enableJavaScript"]); // inherited from config + Assert.IsType>(merged["widths"]); // deep-converted from config + Assert.Equal("FROM_CALL", merged["percyCSS"]); // per-call overrides config + } + + [Fact] + public void MergeSnapshotOptions_NullOptionsAndNoConfig_ReturnsEmpty() + { + // Covers the null-options branch and the no-cli-config branch. + var merged = (Dictionary)InvokePrivate("MergeSnapshotOptions", new object?[] { null })!; + Assert.NotNull(merged); + } + // ===== ResolveReadinessConfig ========================================= [Fact] From 12f1caf29faf0351963e6a169d98868916fdb661 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Thu, 9 Jul 2026 17:04:20 +0530 Subject: [PATCH 7/9] test: cover DeepMerge recursion + config-processing catch (100% line gate) Adds the last two uncovered config-merge paths flagged by the 100% line gate: - MergeSnapshotOptions_DeepMergesNestedObjects: nested objects in both config and per-call options, exercising the DeepMerge recursive branch. - MergeSnapshotOptions_NonObjectCliConfig_SwallowsAndReturnsOptions: a non-object cliConfig makes TryGetProperty throw, covering the catch block. Co-Authored-By: Claude Opus 4.8 --- Percy.Test/PercyLogic.Test.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Percy.Test/PercyLogic.Test.cs b/Percy.Test/PercyLogic.Test.cs index 2db39ac..6a3d6bf 100644 --- a/Percy.Test/PercyLogic.Test.cs +++ b/Percy.Test/PercyLogic.Test.cs @@ -230,6 +230,33 @@ public void MergeSnapshotOptions_NullOptionsAndNoConfig_ReturnsEmpty() Assert.NotNull(merged); } + [Fact] + public void MergeSnapshotOptions_DeepMergesNestedObjects() + { + // Nested objects present in both config and per-call options merge + // recursively (exercises the DeepMerge recursion branch). + SetCliConfigJson("{\"snapshot\":{\"discovery\":{\"networkIdleTimeout\":100,\"disableCache\":true}}}"); + var options = new Dictionary + { + { "discovery", new Dictionary { { "networkIdleTimeout", 200 } } } + }; + var merged = (Dictionary)InvokePrivate("MergeSnapshotOptions", options)!; + var discovery = (Dictionary)merged["discovery"]; + Assert.Equal(200, discovery["networkIdleTimeout"]); // per-call wins at the leaf + Assert.Equal(true, discovery["disableCache"]); // config leaf inherited + } + + [Fact] + public void MergeSnapshotOptions_NonObjectCliConfig_SwallowsAndReturnsOptions() + { + // A non-object cliConfig makes TryGetProperty("snapshot") throw; the + // catch swallows it and per-call options still flow through. + SetCliConfigJson("[1,2,3]"); + var options = new Dictionary { { "percyCSS", "x" } }; + var merged = (Dictionary)InvokePrivate("MergeSnapshotOptions", options)!; + Assert.Equal("x", merged["percyCSS"]); + } + // ===== ResolveReadinessConfig ========================================= [Fact] From d8947d30f45cc8104853f70aefaee5fecec8691b Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Thu, 9 Jul 2026 18:23:15 +0530 Subject: [PATCH 8/9] chore: temporary step to print uncovered coverage lines (will revert) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 366923a..265981c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,3 +63,7 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Run Tests run: npm test -- --no-restore + - name: Print uncovered lines (temporary) + if: always() + run: | + python3 -c "import glob,xml.etree.ElementTree as ET; print([(c.get('filename'),[l.get('number') for l in c.iter('line') if l.get('hits')=='0']) for x in glob.glob('**/*.cobertura.xml',recursive=True) for c in ET.parse(x).getroot().iter('class') if 'Percy.cs' in (c.get('filename') or '')])" From 73d640bef6f944bc87b6df67d8bf2e24da3225d6 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Thu, 9 Jul 2026 18:29:46 +0530 Subject: [PATCH 9/9] refactor: remove dead ConvertJsonArray helper (100% line gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConvertJsonArray had no callers — it was an earlier array-conversion helper superseded by JsonElementToObjectDeep. Its 16 lines were the last uncovered block keeping Percy under the 100% line-coverage gate (98.94%). Remove the dead code. Also reverts the temporary uncovered-line-printing CI step. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 4 ---- Percy/Percy.cs | 18 ------------------ 2 files changed, 22 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 265981c..366923a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,7 +63,3 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Run Tests run: npm test -- --no-restore - - name: Print uncovered lines (temporary) - if: always() - run: | - python3 -c "import glob,xml.etree.ElementTree as ET; print([(c.get('filename'),[l.get('number') for l in c.iter('line') if l.get('hits')=='0']) for x in glob.glob('**/*.cobertura.xml',recursive=True) for c in ET.parse(x).getroot().iter('class') if 'Percy.cs' in (c.get('filename') or '')])" diff --git a/Percy/Percy.cs b/Percy/Percy.cs index 3daed0f..e47baf6 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -1576,24 +1576,6 @@ existing is Dictionary existingDict && return result; } - private static object ConvertJsonArray(JsonElement array) - { - var items = array.EnumerateArray().ToList(); - if (items.Count > 0 && items.All(item => item.ValueKind == JsonValueKind.Number && item.TryGetInt32(out _))) - { - return items.Select(item => item.GetInt32()).ToList(); - } - - return items.Select(item => item.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Number => item.TryGetInt32(out int intVal) ? intVal : (object)item.GetDouble(), - JsonValueKind.String => item.GetString(), - _ => (object)item - }).ToList(); - } - private static bool isResponsiveSnapshotCapture(Dictionary? options) { if (cliConfig == null) return false;