diff --git a/lib/PuppeteerSharp.Tests/AccessibilityTests/AccessibilityTests.cs b/lib/PuppeteerSharp.Tests/AccessibilityTests/AccessibilityTests.cs new file mode 100644 index 000000000..87b97e3e9 --- /dev/null +++ b/lib/PuppeteerSharp.Tests/AccessibilityTests/AccessibilityTests.cs @@ -0,0 +1,340 @@ +using System.Threading.Tasks; +using PuppeteerSharp.PageAccessibility; +using Xunit; +using Xunit.Abstractions; + +namespace PuppeteerSharp.Tests.AccesibilityTests +{ + [Collection("PuppeteerLoaderFixture collection")] + public class AccesibilityTests : PuppeteerPageBaseTest + { + public AccesibilityTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task ShouldWork() + { + await Page.SetContentAsync(@" + + Accessibility Test + + +
Hello World
+

Inputs

+ + + + + + + + + + "); + Assert.Equal( + new SerializedAXNode + { + Role = "WebArea", + Name = "Accessibility Test", + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "text", + Name = "Hello World" + }, + new SerializedAXNode + { + Role = "heading", + Name = "Inputs", + Level = 1 + }, + new SerializedAXNode{ + Role = "textbox", + Name = "Empty input", + Focused = true + }, + new SerializedAXNode{ + Role = "textbox", + Name = "readonly input", + Readonly = true + }, + new SerializedAXNode{ + Role = "textbox", + Name = "disabled input", + Disabled= true + }, + new SerializedAXNode{ + Role = "textbox", + Name = "Input with whitespace", + Value= " " + }, + new SerializedAXNode{ + Role = "textbox", + Name = "", + Value= "value only" + }, + new SerializedAXNode{ + Role = "textbox", + Name = "placeholder", + Value= "and a value" + }, + new SerializedAXNode{ + Role = "textbox", + Name = "placeholder", + Value= "and a value", + Description= "This is a description!"}, + new SerializedAXNode{ + Role= "combobox", + Name= "", + Value= "First Option", + Children= new SerializedAXNode[]{ + new SerializedAXNode + { + Role = "menuitem", + Name = "First Option", + Selected= true + }, + new SerializedAXNode + { + Role = "menuitem", + Name = "Second Option" + } + } + } + } + }, + await Page.Accessibility.SnapshotAsync()); + } + + [Fact] + public async Task ShouldReportUninterestingNodes() + { + await Page.SetContentAsync(""); + Assert.Equal( + new SerializedAXNode + { + Role = "textbox", + Name = "", + Value = "hi", + Focused = true, + Multiline = true, + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "GenericContainer", + Name = "", + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "text", + Name = "hi" + } + } + } + } + }, + FindFocusedNode(await Page.Accessibility.SnapshotAsync(new AccessibilitySnapshotOptions + { + InterestingOnly = false + }))); + } + + [Fact] + public async Task ShouldNotReportTextNodesInsideControls() + { + await Page.SetContentAsync(@" +
+
Tab1
+
Tab2
+
"); + Assert.Equal( + new SerializedAXNode + { + Role = "WebArea", + Name = "", + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "tab", + Name = "Tab1", + Selected = true + }, + new SerializedAXNode + { + Role = "tab", + Name = "Tab2" + } + } + }, + await Page.Accessibility.SnapshotAsync()); + } + + [Fact] + public async Task RichTextEditableFieldsShouldHaveChildren() + { + await Page.SetContentAsync(@" +
+ Edit this image: my fake image +
"); + Assert.Equal( + new SerializedAXNode + { + Role = "GenericContainer", + Name = "", + Value = "Edit this image: ", + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "text", + Name = "Edit this image:" + }, + new SerializedAXNode + { + Role = "img", + Name = "my fake image" + } + } + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task RichTextEditableFieldsWithRoleShouldHaveChildren() + { + await Page.SetContentAsync(@" +
+ Edit this image: my fake image +
"); + Assert.Equal( + new SerializedAXNode + { + Role = "textbox", + Name = "", + Value = "Edit this image: ", + Children = new SerializedAXNode[] + { + new SerializedAXNode + { + Role = "text", + Name = "Edit this image:" + }, + new SerializedAXNode + { + Role = "img", + Name = "my fake image" + } + } + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task PlainTextFieldWithRoleShouldNotHaveChildren() + { + await Page.SetContentAsync("
Edit this image:my fake image
"); + Assert.Equal( + new SerializedAXNode + { + Role = "textbox", + Name = "", + Value = "Edit this image:" + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task PlainTextFieldWithTabindexAndWithoutRoleShouldNotHaveContent() + { + await Page.SetContentAsync("
Edit this image:my fake image
"); + Assert.Equal( + new SerializedAXNode + { + Role = "textbox", + Name = "", + Value = "Edit this image:" + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task NonEditableTextboxWithRoleAndTabIndexAndLabelShouldNotHaveChildren() + { + await Page.SetContentAsync(@" +
+ this is the inner content + yo +
"); + Assert.Equal( + new SerializedAXNode + { + Role = "textbox", + Name = "my favorite textbox", + Value = "this is the inner content " + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task CheckboxWithAndTabIndexAndLabelShouldNotHaveChildren() + { + await Page.SetContentAsync(@" +
+ this is the inner content + yo +
"); + Assert.Equal( + new SerializedAXNode + { + Role = "checkbox", + Name = "my favorite checkbox", + Checked = CheckedState.True + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + [Fact] + public async Task CheckboxWithoutLabelShouldNotHaveChildren() + { + await Page.SetContentAsync(@" +
+ this is the inner content + yo +
"); + Assert.Equal( + new SerializedAXNode + { + Role = "checkbox", + Name = "this is the inner content yo", + Checked = CheckedState.True + }, + (await Page.Accessibility.SnapshotAsync()).Children[0]); + } + + private SerializedAXNode FindFocusedNode(SerializedAXNode serializedAXNode) + { + if (serializedAXNode.Focused) + { + return serializedAXNode; + } + foreach (var item in serializedAXNode.Children) + { + var focusedChild = FindFocusedNode(item); + if (focusedChild != null) + { + return focusedChild; + } + } + + return null; + } + } +} diff --git a/lib/PuppeteerSharp/Messaging/AccessibilityGetFullAXTreeResponse.cs b/lib/PuppeteerSharp/Messaging/AccessibilityGetFullAXTreeResponse.cs new file mode 100644 index 000000000..49035e27c --- /dev/null +++ b/lib/PuppeteerSharp/Messaging/AccessibilityGetFullAXTreeResponse.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace PuppeteerSharp.Messaging +{ + internal class AccessibilityGetFullAXTreeResponse + { + [JsonProperty("nodes")] + public IEnumerable Nodes { get; set; } + + public class AXTreeNode + { + [JsonProperty("nodeId")] + public string NodeId { get; set; } + [JsonProperty("childIds")] + public IEnumerable ChildIds { get; set; } + [JsonProperty("name")] + public AXTreePropertyValue Name { get; set; } + [JsonProperty("value")] + public AXTreePropertyValue Value { get; set; } + [JsonProperty("description")] + public AXTreePropertyValue Description { get; set; } + [JsonProperty("role")] + public AXTreePropertyValue Role { get; set; } + [JsonProperty("properties")] + public IEnumerable Properties { get; set; } + } + + public class AXTreeProperty + { + [JsonProperty("name")] + public string Name { get; internal set; } + [JsonProperty("value")] + public AXTreePropertyValue Value { get; set; } + } + + public class AXTreePropertyValue + { + [JsonProperty("type")] + public string Type { get; set; } + [JsonProperty("value")] + public JToken Value { get; set; } + } + } +} diff --git a/lib/PuppeteerSharp/Page.cs b/lib/PuppeteerSharp/Page.cs index 151bc537f..8c0150268 100644 --- a/lib/PuppeteerSharp/Page.cs +++ b/lib/PuppeteerSharp/Page.cs @@ -14,6 +14,7 @@ using PuppeteerSharp.Media; using PuppeteerSharp.Messaging; using PuppeteerSharp.Mobile; +using PuppeteerSharp.PageAccessibility; using PuppeteerSharp.PageCoverage; namespace PuppeteerSharp @@ -71,7 +72,7 @@ private Page( _pageBindings = new Dictionary(); _workers = new Dictionary(); _logger = Client.Connection.LoggerFactory.CreateLogger(); - + Accessibility = new Accessibility(client); _ignoreHTTPSErrors = ignoreHTTPSErrors; _screenshotTaskQueue = screenshotTaskQueue; @@ -298,6 +299,11 @@ public int DefaultNavigationTimeout /// public bool IsClosed { get; private set; } + /// + /// Gets the accessibility. + /// + public Accessibility Accessibility { get; } + internal bool JavascriptEnabled { get; set; } = true; #endregion diff --git a/lib/PuppeteerSharp/PageAccessibility/AXNode.cs b/lib/PuppeteerSharp/PageAccessibility/AXNode.cs new file mode 100644 index 000000000..ce1f2d7b7 --- /dev/null +++ b/lib/PuppeteerSharp/PageAccessibility/AXNode.cs @@ -0,0 +1,244 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using PuppeteerSharp.Messaging; +using Newtonsoft.Json.Linq; +using PuppeteerSharp.Helpers; + +namespace PuppeteerSharp.PageAccessibility +{ + internal class AXNode + { + internal AccessibilityGetFullAXTreeResponse.AXTreeNode Payload { get; } + public List Children { get; } + public bool Focusable { get; set; } + + private readonly string _name; + private string _role; + private readonly bool _richlyEditable; + private readonly bool _editable; + private readonly bool _expanded; + private bool? _cachedHasFocusableChild; + + public AXNode(AccessibilityGetFullAXTreeResponse.AXTreeNode payload) + { + Payload = payload; + Children = new List(); + + _name = payload.Name != null ? payload.Name.Value.ToObject() : string.Empty; + _role = payload.Role != null ? payload.Role.Value.ToObject() : "Unknown"; + + _richlyEditable = payload.Properties.FirstOrDefault(p => p.Name == "editable")?.Value.Value.ToObject() == "richtext"; + _editable |= _richlyEditable; + _expanded = payload.Properties.FirstOrDefault(p => p.Name == "expanded")?.Value.Value.ToObject() == true; + Focusable = payload.Properties.FirstOrDefault(p => p.Name == "focusable")?.Value.Value.ToObject() == true; + } + + internal static AXNode CreateTree(IEnumerable payloads) + { + var nodeById = new Dictionary(); + foreach (var payload in payloads) + { + nodeById[payload.NodeId] = new AXNode(payload); + } + foreach (var node in nodeById.Values) + { + foreach (var childId in node.Payload.ChildIds) + { + node.Children.Add(nodeById[childId]); + } + } + return nodeById.Values.FirstOrDefault(); + } + + private bool IsPlainTextField() + => !_richlyEditable && (_editable || _role == "textbox" || _role == "ComboBox" || _role == "searchbox"); + + private bool IsTextOnlyObject() + => _role == "LineBreak" || + _role == "text" || + _role == "InlineTextBox"; + + private bool HasFocusableChild() + { + if (!_cachedHasFocusableChild.HasValue) + { + _cachedHasFocusableChild = Children.Any(c => c.Focusable || c.HasFocusableChild()); + } + return _cachedHasFocusableChild.Value; + } + + internal bool IsLeafNode() + { + if (Children.Count == 0) + { + return true; + } + + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (IsPlainTextField() || IsTextOnlyObject()) + { + return true; + } + + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (_role) + { + case "doc-cover": + case "graphics-symbol": + case "img": + case "Meter": + case "scrollbar": + case "slider": + case "separator": + case "progressbar": + return true; + } + + // Here and below: Android heuristics + if (HasFocusableChild()) + { + return false; + } + if (Focusable && !string.IsNullOrEmpty(_name)) + { + return true; + } + if (_role == "heading" && !string.IsNullOrEmpty(_name)) + { + return true; + } + return false; + } + + internal bool IsControl() + { + switch (_role) + { + case "button": + case "checkbox": + case "ColorWell": + case "combobox": + case "DisclosureTriangle": + case "listbox": + case "menu": + case "menubar": + case "menuitem": + case "menuitemcheckbox": + case "menuitemradio": + case "radio": + case "scrollbar": + case "searchbox": + case "slider": + case "spinbutton": + case "switch": + case "tab": + case "textbox": + case "tree": + return true; + default: + return false; + } + } + + internal bool IsInteresting(bool insideControl) + { + if (_role == "Ignored") + { + return false; + } + + if (Focusable || _richlyEditable) + { + return true; + } + // If it's not focusable but has a control role, then it's interesting. + if (IsControl()) + { + return true; + } + // A non focusable child of a control is not interesting + if (insideControl) + { + return false; + } + return IsLeafNode() && !string.IsNullOrEmpty(_name); + } + + internal SerializedAXNode Serialize() + { + var properties = new Dictionary(); + foreach (var property in Payload.Properties) + { + properties[property.Name.ToLower()] = property.Value.Value; + } + + if (Payload.Name != null) + { + properties["name"] = Payload.Name.Value; + } + if (Payload.Value != null) + { + properties["value"] = Payload.Value.Value; + } + if (Payload.Description != null) + { + properties["description"] = Payload.Description.Value; + } + + var node = new SerializedAXNode + { + Role = _role, + Name = properties.GetValueOrDefault("name")?.ToObject(), + Value = properties.GetValueOrDefault("value")?.ToObject(), + Description = properties.GetValueOrDefault("description")?.ToObject(), + KeyShortcuts = properties.GetValueOrDefault("keyshortcuts")?.ToObject(), + RoleDescription = properties.GetValueOrDefault("roledescription")?.ToObject(), + ValueText = properties.GetValueOrDefault("valuetext")?.ToObject(), + Disabled = properties.GetValueOrDefault("disabled")?.ToObject() ?? false, + Expanded = properties.GetValueOrDefault("expanded")?.ToObject() ?? false, + // WebArea"s treat focus differently than other nodes. They report whether their frame has focus, + // not whether focus is specifically on the root node. + Focused = properties.GetValueOrDefault("focused")?.ToObject() == true && _role != "WebArea", + Modal = properties.GetValueOrDefault("modal")?.ToObject() ?? false, + Multiline = properties.GetValueOrDefault("multiline")?.ToObject() ?? false, + Multiselectable = properties.GetValueOrDefault("multiselectable")?.ToObject() ?? false, + Readonly = properties.GetValueOrDefault("readonly")?.ToObject() ?? false, + Required = properties.GetValueOrDefault("required")?.ToObject() ?? false, + Selected = properties.GetValueOrDefault("selected")?.ToObject() ?? false, + Checked = GetCheckedState(properties.GetValueOrDefault("checked")?.ToObject()), + Pressed = GetCheckedState(properties.GetValueOrDefault("pressed")?.ToObject()), + Level = properties.GetValueOrDefault("level")?.ToObject() ?? 0, + ValueMax = properties.GetValueOrDefault("valuemax")?.ToObject() ?? 0, + ValueMin = properties.GetValueOrDefault("valuemin")?.ToObject() ?? 0, + AutoComplete = GetIfNotFalse(properties.GetValueOrDefault("autocomplete")?.ToObject()), + HasPopup = GetIfNotFalse(properties.GetValueOrDefault("haspopup")?.ToObject()), + Invalid = GetIfNotFalse(properties.GetValueOrDefault("invalid")?.ToObject()), + Orientation = GetIfNotFalse(properties.GetValueOrDefault("orientation")?.ToObject()) + }; + + return node; + } + + private string GetIfNotFalse(string value) => value != null && value != "false" ? value : null; + + private CheckedState GetCheckedState(string value) + { + switch (value) + { + case "mixed": + return CheckedState.Mixed; + case "true": + return CheckedState.True; + default: + return CheckedState.False; + } + } + } +} \ No newline at end of file diff --git a/lib/PuppeteerSharp/PageAccessibility/Accessibility.cs b/lib/PuppeteerSharp/PageAccessibility/Accessibility.cs new file mode 100644 index 000000000..288e50732 --- /dev/null +++ b/lib/PuppeteerSharp/PageAccessibility/Accessibility.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using PuppeteerSharp.Messaging; + +namespace PuppeteerSharp.PageAccessibility +{ + /// + /// The Accessibility class provides methods for inspecting Chromium's accessibility tree. + /// The accessibility tree is used by assistive technology such as screen readers. + /// + /// Accessibility is a very platform-specific thing. On different platforms, there are different screen readers that might have wildly different output. + /// Blink - Chrome's rendering engine - has a concept of "accessibility tree", which is than translated into different platform-specific APIs. + /// Accessibility namespace gives users access to the Blink Accessibility Tree. + /// Most of the accessibility tree gets filtered out when converting from Blink AX Tree to Platform-specific AX-Tree or by screen readers themselves. + /// By default, Puppeteer tries to approximate this filtering, exposing only the "interesting" nodes of the tree. + /// + public class Accessibility + { + private readonly CDPSession _client; + + /// + /// Initializes a new instance of the class. + /// + /// Client. + public Accessibility(CDPSession client) => _client = client; + + /// + /// Snapshots the async. + /// + /// The async. + /// Options. + public async Task SnapshotAsync(AccessibilitySnapshotOptions options = null) + { + var nodes = (await _client.SendAsync("Accessibility.getFullAXTree")).Nodes; + var root = AXNode.CreateTree(nodes); + if (options?.InterestingOnly == false) + { + return SerializeTree(root)[0]; + } + + var interestingNodes = new List(); + CollectInterestingNodes(interestingNodes, root, false); + return SerializeTree(root, interestingNodes)[0]; + } + + private void CollectInterestingNodes(List collection, AXNode node, bool insideControl) + { + if (node.IsInteresting(insideControl)) + { + collection.Add(node); + } + if (node.IsLeafNode()) + { + return; + } + insideControl = insideControl || node.IsControl(); + foreach (var child in node.Children) + { + CollectInterestingNodes(collection, child, insideControl); + } + } + + private SerializedAXNode[] SerializeTree(AXNode node, List whitelistedNodes = null) + { + var children = new List(); + foreach (var child in node.Children) + { + children.AddRange(SerializeTree(child, whitelistedNodes)); + } + if (whitelistedNodes?.Contains(node) == false) + { + return children.ToArray(); + } + + var serializedNode = node.Serialize(); + if (children.Count > 0) + { + serializedNode.Children = children.ToArray(); + } + return new[] { serializedNode }; + } + } +} \ No newline at end of file diff --git a/lib/PuppeteerSharp/PageAccessibility/AccessibilitySnapshotOptions.cs b/lib/PuppeteerSharp/PageAccessibility/AccessibilitySnapshotOptions.cs new file mode 100644 index 000000000..3a7caa47f --- /dev/null +++ b/lib/PuppeteerSharp/PageAccessibility/AccessibilitySnapshotOptions.cs @@ -0,0 +1,14 @@ +namespace PuppeteerSharp.PageAccessibility +{ + /// + /// + /// + /// + public class AccessibilitySnapshotOptions + { + /// + /// Prune uninteresting nodes from the tree. Defaults to true. + /// + public bool InterestingOnly { get; set; } = true; + } +} \ No newline at end of file diff --git a/lib/PuppeteerSharp/PageAccessibility/CheckedState.cs b/lib/PuppeteerSharp/PageAccessibility/CheckedState.cs new file mode 100644 index 000000000..b05fc394b --- /dev/null +++ b/lib/PuppeteerSharp/PageAccessibility/CheckedState.cs @@ -0,0 +1,21 @@ +namespace PuppeteerSharp.PageAccessibility +{ + /// + /// Three-state boolean. See and + /// + public enum CheckedState + { + /// + /// Flse. + /// + False = 0, + /// + /// True. + /// + True, + /// + /// Mixed. + /// + Mixed + } +} \ No newline at end of file diff --git a/lib/PuppeteerSharp/PageAccessibility/SerializedAXNode.cs b/lib/PuppeteerSharp/PageAccessibility/SerializedAXNode.cs new file mode 100644 index 000000000..51d57db34 --- /dev/null +++ b/lib/PuppeteerSharp/PageAccessibility/SerializedAXNode.cs @@ -0,0 +1,177 @@ +using System; +using System.Linq; + +namespace PuppeteerSharp.PageAccessibility +{ + /// + /// AXNode. + /// + public class SerializedAXNode : IEquatable + { + /// + /// The role. + /// + public string Role { get; set; } + /// + /// A human readable name for the node. + /// + public string Name { get; set; } + /// + /// The current value of the node. + /// + public string Value { get; set; } + /// + /// An additional human readable description of the node. + /// + public string Description { get; set; } + /// + /// Keyboard shortcuts associated with this node. + /// + public string KeyShortcuts { get; set; } + /// + /// A human readable alternative to the role. + /// + public string RoleDescription { get; set; } + /// + /// A description of the current value. + /// + public string ValueText { get; set; } + /// + /// Whether the node is disabled. + /// + public bool Disabled { get; set; } + /// + /// Whether the node is expanded or collapsed. + /// + public bool Expanded { get; set; } + /// + /// Whether the node is focused. + /// + public bool Focused { get; set; } + /// + /// Whether the node is modal. + /// + public bool Modal { get; set; } + /// + /// Whether the node text input supports multiline. + /// + public bool Multiline { get; set; } + /// + /// Whether more than one child can be selected. + /// + public bool Multiselectable { get; set; } + /// + /// Whether the node is read only. + /// + public bool Readonly { get; set; } + /// + /// Whether the node is required. + /// + public bool Required { get; set; } + /// + /// Whether the node is selected in its parent node. + /// + public bool Selected { get; set; } + /// + /// Whether the checkbox is checked, or "mixed". + /// + public CheckedState Checked { get; set; } + /// + /// Whether the toggle button is checked, or "mixed". + /// + public CheckedState Pressed { get; set; } + /// + /// The level of a heading. + /// + public int Level { get; set; } + /// + /// The minimum value in a node. + /// + public int ValueMin { get; set; } + /// + /// The maximum value in a node. + /// + public int ValueMax { get; set; } + /// + /// What kind of autocomplete is supported by a control. + /// + public string AutoComplete { get; set; } + /// + /// What kind of popup is currently being shown for a node. + /// + public string HasPopup { get; set; } + /// + /// Whether and in what way this node's value is invalid. + /// + public string Invalid { get; set; } + /// + /// Whether the node is oriented horizontally or vertically. + /// + public string Orientation { get; set; } + /// + /// Child nodes of this node, if any. + /// + public SerializedAXNode[] Children { get; set; } + + /// + public bool Equals(SerializedAXNode other) + => ReferenceEquals(this, other) || + ( + Role == other.Role && + Name == other.Name && + Value == other.Value && + Description == other.Description && + KeyShortcuts == other.KeyShortcuts && + RoleDescription == other.RoleDescription && + ValueText == other.ValueText && + AutoComplete == other.AutoComplete && + HasPopup == other.HasPopup && + Orientation == other.Orientation && + Disabled == other.Disabled && + Expanded == other.Expanded && + Focused == other.Focused && + Modal == other.Modal && + Multiline == other.Multiline && + Multiselectable == other.Multiselectable && + Readonly == other.Readonly && + Required == other.Required && + Selected == other.Selected && + Checked == other.Checked && + Pressed == other.Pressed && + Level == other.Level && + ValueMin == other.ValueMin && + ValueMax == other.ValueMax && + (Children == other.Children || Children.SequenceEqual(other.Children)) + ); + + /// + public override bool Equals(object obj) => obj is SerializedAXNode s && Equals(s); + /// + public override int GetHashCode() + => Role.GetHashCode() ^ + Name.GetHashCode() ^ + Value.GetHashCode() ^ + Description.GetHashCode() ^ + KeyShortcuts.GetHashCode() ^ + RoleDescription.GetHashCode() ^ + ValueText.GetHashCode() ^ + AutoComplete.GetHashCode() ^ + HasPopup.GetHashCode() ^ + Orientation.GetHashCode() ^ + Disabled.GetHashCode() ^ + Expanded.GetHashCode() ^ + Focused.GetHashCode() ^ + Modal.GetHashCode() ^ + Multiline.GetHashCode() ^ + Multiselectable.GetHashCode() ^ + Readonly.GetHashCode() ^ + Required.GetHashCode() ^ + Selected.GetHashCode() ^ + Pressed.GetHashCode() ^ + Checked.GetHashCode() ^ + Level.GetHashCode() ^ + ValueMin.GetHashCode() ^ + ValueMax.GetHashCode() ^ + Children.GetHashCode(); + } +} \ No newline at end of file