Skip to content
101 changes: 101 additions & 0 deletions Percy.Test/Percy.Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -385,6 +386,106 @@ 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<string, object> InvokeMerge(
JsonElement cliConfig, Dictionary<string, object>? 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<string, object>)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<JsonElement>(
"{\"snapshot\":{\"enableJavaScript\":true,\"percyCSS\":\"FROM_CONFIG\"}}");

var perSnapshotOptions = new Dictionary<string, object>
{
{ "percyCSS", "FROM_CALL" }
};

Dictionary<string, object> 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();
}
}

[Fact]
public void NestedConfigObjectIsDeepMergedWithPerSnapshotOptions()
{
try
{
// .percy.yml config: nested discovery object with two leaves.
JsonElement cliConfig = JsonSerializer.Deserialize<JsonElement>(
"{\"snapshot\":{\"discovery\":{\"networkIdleTimeout\":50,\"disableCache\":false}}}");

// Per-snapshot call overrides only one leaf of the nested object.
var perSnapshotOptions = new Dictionary<string, object>
{
{
"discovery", new Dictionary<string, object>
{
{ "disableCache", true }
}
}
};

Dictionary<string, object> merged = InvokeMerge(cliConfig, perSnapshotOptions);

// Nested object is deep-merged, not replaced wholesale.
Assert.True(merged.ContainsKey("discovery"));
var discovery = Assert.IsType<Dictionary<string, object>>(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
{
[Fact]
Expand Down
128 changes: 124 additions & 4 deletions Percy/Percy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,11 @@
};
public class Region
{
public RegionElementSelector elementSelector { get; set; }

Check warning on line 240 in Percy/Percy.cs

View workflow job for this annotation

GitHub Actions / Test

Non-nullable property 'elementSelector' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public RegionPadding padding { get; set; }

Check warning on line 241 in Percy/Percy.cs

View workflow job for this annotation

GitHub Actions / Test

Non-nullable property 'padding' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public string algorithm { get; set; }

Check warning on line 242 in Percy/Percy.cs

View workflow job for this annotation

GitHub Actions / Test

Non-nullable property 'algorithm' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public RegionConfiguration configuration { get; set; }

Check warning on line 243 in Percy/Percy.cs

View workflow job for this annotation

GitHub Actions / Test

Non-nullable property 'configuration' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public RegionAssertion assertion { get; set; }

Check warning on line 244 in Percy/Percy.cs

View workflow job for this annotation

GitHub Actions / Test

Non-nullable property 'assertion' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

// Rename the nested class to RegionElementSelector or another unique name
public class RegionElementSelector
Expand Down Expand Up @@ -1476,6 +1476,124 @@
return domSnapshots;
}

private static Dictionary<string, object> MergeSnapshotOptions(Dictionary<string, object>? options)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] No tests for the new merge/array-typing logic

MergeSnapshotOptions and ConvertJsonArray (including the widths-typing fix that was the prior FAIL) ship with no unit tests, though the repo has a Percy.Test project. Regressions in array typing or merge precedence would go uncaught.

Suggestion: Add tests covering: config widths array converting to List<int> consumable by CaptureResponsiveDom; per-call options overriding config; empty and mixed-type arrays; and absent/non-object snapshot config falling back cleanly.

Reviewer: stack:code-review

{
var merged = new Dictionary<string, object>();
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())
{
// Recursively convert config values so nested JSON
// objects become Dictionary<string, object> and can be
// deep-merged with per-call options below.
var converted = JsonElementToObjectDeep(prop.Value);
if (converted != null)
merged[prop.Name] = converted;
}
}
}
catch (Exception) { }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Empty catch swallows config-merge errors

catch (Exception) { } discards every error during config extraction with no log, so a malformed cliConfig silently contributes nothing and the user gets no signal.

Suggestion: Log at debug, matching the existing pattern: Log($"Error reading snapshot config: {e.Message}", "debug");

Reviewer: stack:code-review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Silently swallowed merge error

catch (Exception) { } discards any failure while reading cliConfig.snapshot, so a misconfigured .percy.yml silently drops all config-seeded options with no diagnostic.

Suggestion: Log at debug level inside the catch (the file already has a Log(..., "debug") helper) so misconfiguration is diagnosable while still degrading gracefully.

Reviewer: stack:code-review

}
if (options != null)
{
// Deep-merge: nested objects merge recursively (per-call wins at
// leaves), arrays/scalars replace. Matches the JS sdk-utils fix.
merged = DeepMerge(merged, options);
}
return merged;
}

// Recursively converts a JSON element into plain CLR objects:
// Object -> Dictionary<string, object> (recursive),
// Array -> List<object> (recursive),
// primitives -> bool / int / double / string.
private static object? JsonElementToObjectDeep(JsonElement el)
{
switch (el.ValueKind)
{
case JsonValueKind.Object:
var dict = new Dictionary<string, object>();
foreach (JsonProperty prop in el.EnumerateObject())
{
var converted = JsonElementToObjectDeep(prop.Value);
if (converted != null)
dict[prop.Name] = converted;
}
return dict;
case JsonValueKind.Array:
var items = el.EnumerateArray().ToList();
// All-integer arrays (e.g. config `widths`) must stay List<int> 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<object>();
foreach (JsonElement item in items)
{
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<string, object>, recurse; otherwise the
// override value wins (arrays and scalars replace).
private static Dictionary<string, object> DeepMerge(
Dictionary<string, object> baseDict, Dictionary<string, object> overrideDict)
{
var result = new Dictionary<string, object>(baseDict);
foreach (var kvp in overrideDict)
{
if (result.TryGetValue(kvp.Key, out var existing) &&
existing is Dictionary<string, object> existingDict &&
kvp.Value is Dictionary<string, object> overrideNested)
{
result[kvp.Key] = DeepMerge(existingDict, overrideNested);
}
else
{
result[kvp.Key] = kvp.Value;
}
}
return result;
}

private static object ConvertJsonArray(JsonElement array)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] ConvertJsonArray is untested

The added test only covers scalar (bool/string) merge precedence; the widths-style array conversion path has no direct coverage.

Suggestion: Add a case where cliConfig.snapshot.widths is a JSON number array and assert it converts to a typed List<int> and survives the merge.

Reviewer: stack:code-review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Dead code: ConvertJsonArray is never called

ConvertJsonArray is a private static method with no call site. JsonElementToObjectDeep (used by MergeSnapshotOptions) already implements the all-integer→List<int> logic, so this method is unused and may trigger unused-member analyzer warnings.

Suggestion: Delete ConvertJsonArray, or route the array branch of JsonElementToObjectDeep through it to remove the duplication.

Reviewer: stack:code-review

{
var items = array.EnumerateArray().ToList();
if (items.Count > 0 && items.All(item => item.ValueKind == JsonValueKind.Number && item.TryGetInt32(out _)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Empty config widths: [] re-triggers the InvalidCastException

ConvertJsonArray only returns List<int> when items.Count > 0. An empty config array falls through to the second branch returning an empty List<object>. Since MergeSnapshotOptions still adds the widths key, CaptureResponsiveDom (line 774, ContainsKey("widths") true) then runs (List<int>)options["widths"] on a List<object> and throws — the same failure this PR fixes, for the empty-widths edge case.

Suggestion: Return List<int> for empty numeric arrays too, e.g. if (items.Count == 0 || items.All(item => item.ValueKind == JsonValueKind.Number && item.TryGetInt32(out _))), or make line 774 defensive.

Reviewer: stack:code-review

{
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<string, object>? options)
{
if (cliConfig == null) return false;
Expand Down Expand Up @@ -1531,14 +1649,16 @@
// Non-Chrome browsers and missing ExecuteCdpCommand are no-ops.
ExposeClosedShadowRoots(driver);

// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Config-seeded widths crashes responsive capture

This now passes mergedOptions into CaptureResponsiveDom. When widths comes from .percy.yml, MergeSnapshotOptions/JsonElementToObjectDeep convert it to List<object>, but CaptureResponsiveDom (line 774) hard-casts options["widths"] to List<int> and throws InvalidCastException. It's swallowed by Snapshot's outer catch, so a config-driven responsive snapshot that previously fell back to default widths now produces no snapshot at all.

Suggestion: Coerce widths at the consumption site regardless of element type:

List<int> widths = new List<int>();
if (options != null && options.TryGetValue("widths", out var raw)) {
    if (raw is List<int> ints) widths = ints;
    else if (raw is List<object> objs) widths = objs.Select(Convert.ToInt32).ToList();
}

or route array conversion through the (currently unused) ConvertJsonArray, which already yields List<int> for homogeneous int arrays.

Reviewer: stack:code-review

} else {
domSnapshot = getSerializedDom(driver, cookies, options, _dom);
domSnapshot = getSerializedDom(driver, cookies, mergedOptions, _dom);
}

Options snapshotOptions = new Options {
Expand Down
Loading