Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions lib/PuppeteerSharp.Tests/AccessibilityTests/AccessibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,89 @@ this is the inner content
}));
}

[Test, PuppeteerTest("accessibility.spec", "Accessibility", "should capture new accessibility properties and not prune them")]
public async Task ShouldCaptureNewAccessibilityPropertiesAndNotPruneThem()
{
await Page.SetContentAsync(@"
<div role=""alert"" aria-busy=""true"">This is an alert</div>
<div aria-live=""polite"" aria-atomic=""true"" aria-relevant=""additions text"">
This is polite live region
</div>
<div aria-modal=""true"" role=""dialog"" aria-roledescription=""My Modal"">
Modal content
</div>
<div id=""error"">Error message</div>
<input aria-invalid=""true"" aria-errormessage=""error"" value=""invalid input"">
<div id=""details"">Additional details</div>
<div aria-details=""details"">Element with details</div>
<div aria-description=""This is a description""></div>");

var snapshot = await Page.Accessibility.SnapshotAsync();

Assert.That(snapshot.Role, Is.EqualTo("RootWebArea"));
Assert.That(snapshot.Children.Length, Is.GreaterThanOrEqualTo(8));

// alert node with busy, live, atomic
var alertNode = snapshot.Children[0];
Assert.That(alertNode.Role, Is.EqualTo("alert"));
Assert.That(alertNode.Name, Is.EqualTo(string.Empty));
Assert.That(alertNode.Busy, Is.True);
Assert.That(alertNode.Live, Is.EqualTo("assertive"));
Assert.That(alertNode.Atomic, Is.True);
Assert.That(alertNode.Children, Has.Length.EqualTo(1));
Assert.That(alertNode.Children[0].Role, Is.EqualTo("StaticText"));
Assert.That(alertNode.Children[0].Name, Is.EqualTo("This is an alert"));

// polite live region with atomic and relevant
var liveRegionNode = snapshot.Children[1];
Assert.That(liveRegionNode.Role, Is.EqualTo("generic"));
Assert.That(liveRegionNode.Name, Is.EqualTo(string.Empty));
Assert.That(liveRegionNode.Live, Is.EqualTo("polite"));
Assert.That(liveRegionNode.Atomic, Is.True);
Assert.That(liveRegionNode.Relevant, Is.EqualTo("additions text"));
Assert.That(liveRegionNode.Children, Has.Length.EqualTo(1));
Assert.That(liveRegionNode.Children[0].Role, Is.EqualTo("StaticText"));
Assert.That(liveRegionNode.Children[0].Name, Is.EqualTo("This is polite live region"));

// dialog with modal and roledescription
var dialogNode = snapshot.Children[2];
Assert.That(dialogNode.Role, Is.EqualTo("dialog"));
Assert.That(dialogNode.Name, Is.EqualTo(string.Empty));
Assert.That(dialogNode.Modal, Is.True);
Assert.That(dialogNode.RoleDescription, Is.EqualTo("My Modal"));
Assert.That(dialogNode.Children, Has.Length.EqualTo(1));
Assert.That(dialogNode.Children[0].Role, Is.EqualTo("StaticText"));
Assert.That(dialogNode.Children[0].Name, Is.EqualTo("Modal content"));

// Error message static text
Assert.That(snapshot.Children[3].Role, Is.EqualTo("StaticText"));
Assert.That(snapshot.Children[3].Name, Is.EqualTo("Error message"));

// input with invalid and errormessage
var inputNode = snapshot.Children[4];
Assert.That(inputNode.Role, Is.EqualTo("textbox"));
Assert.That(inputNode.Value, Is.EqualTo("invalid input"));
Assert.That(inputNode.Invalid, Is.EqualTo("true"));
Assert.That(inputNode.Errormessage, Is.EqualTo("error"));

// Additional details static text
Assert.That(snapshot.Children[5].Role, Is.EqualTo("StaticText"));
Assert.That(snapshot.Children[5].Name, Is.EqualTo("Additional details"));

// element with details
var detailsNode = snapshot.Children[6];
Assert.That(detailsNode.Role, Is.EqualTo("generic"));
Assert.That(detailsNode.Details, Is.EqualTo("details"));
Assert.That(detailsNode.Children, Has.Length.EqualTo(1));
Assert.That(detailsNode.Children[0].Role, Is.EqualTo("StaticText"));
Assert.That(detailsNode.Children[0].Name, Is.EqualTo("Element with details"));

// element with description only (no name)
var descriptionNode = snapshot.Children[7];
Assert.That(descriptionNode.Role, Is.EqualTo("generic"));
Assert.That(descriptionNode.Description, Is.EqualTo("This is a description"));
}

private SerializedAXNode FindFocusedNode(SerializedAXNode serializedAXNode)
{
if (serializedAXNode.Focused)
Expand Down
66 changes: 63 additions & 3 deletions lib/PuppeteerSharp/PageAccessibility/AXNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,36 @@ internal class AXNode
private readonly bool _richlyEditable;
private readonly bool _editable;
private readonly bool _hidden;
private readonly bool _busy;
private readonly bool _modal;
private readonly bool _hasErrormessage;
private readonly bool _hasDetails;
private readonly string _role;
private readonly string _description;
private readonly string _roledescription;
private readonly string _live;
private readonly bool _ignored;
private bool? _cachedHasFocusableChild;

private AXNode(AccessibilityGetFullAXTreeResponse.AXTreeNode payload)
{
Payload = payload;

_name = payload.Name != null ? payload.Name.Value.ToObject<string>() : string.Empty;
_role = payload.Role != null ? payload.Role.Value.ToObject<string>() : "Unknown";
_ignored = payload.Ignored;
_name = payload.Name != null ? payload.Name.Value.ToObject<string>() : string.Empty;
_description = payload.Description != null ? payload.Description.Value.ToObject<string>() : null;

_richlyEditable = payload.Properties?.FirstOrDefault(p => p.Name == "editable")?.Value.Value.ToObject<string>() == "richtext";
_editable |= _richlyEditable;
_hidden = payload.Properties?.FirstOrDefault(p => p.Name == "hidden")?.Value.Value.ToObject<bool>() == true;
Focusable = payload.Properties?.FirstOrDefault(p => p.Name == "focusable")?.Value.Value.ToObject<bool>() == true;
_busy = GetBooleanProperty(payload, "busy");
_live = payload.Properties?.FirstOrDefault(p => p.Name == "live")?.Value.Value.ToObject<string>();
_modal = GetBooleanProperty(payload, "modal");
_roledescription = payload.Properties?.FirstOrDefault(p => p.Name == "roledescription")?.Value.Value.ToObject<string>();
_hasErrormessage = payload.Properties?.Any(p => p.Name == "errormessage") == true;
_hasDetails = payload.Properties?.Any(p => p.Name == "details") == true;
}

public List<AXNode> Children { get; } = new();
Expand Down Expand Up @@ -168,7 +182,14 @@ internal bool IsInteresting(bool insideControl)
return false;
}

if (Focusable || _richlyEditable)
if (Focusable ||
_richlyEditable ||
_busy ||
(_live != null && _live != "off") ||
_modal ||
_hasErrormessage ||
_hasDetails ||
_roledescription != null)
{
return true;
}
Expand All @@ -185,7 +206,7 @@ internal bool IsInteresting(bool insideControl)
return false;
}

return IsLeafNode() && !string.IsNullOrEmpty(_name);
return IsLeafNode() && (!string.IsNullOrEmpty(_name) || !string.IsNullOrEmpty(_description));
}

internal SerializedAXNode Serialize()
Expand Down Expand Up @@ -236,6 +257,8 @@ internal SerializedAXNode Serialize()
Readonly = properties.GetValue("readonly")?.ToObject<bool>() ?? false,
Required = properties.GetValue("required")?.ToObject<bool>() ?? false,
Selected = properties.GetValue("selected")?.ToObject<bool>() ?? false,
Busy = GetBooleanPropertyValue(properties, "busy"),
Atomic = GetBooleanPropertyValue(properties, "atomic"),
Checked = GetCheckedState(properties.GetValue("checked")?.ToObject<string>()),
Pressed = GetCheckedState(properties.GetValue("pressed")?.ToObject<string>()),
Level = properties.GetValue("level")?.ToObject<int>() ?? 0,
Expand All @@ -245,11 +268,48 @@ internal SerializedAXNode Serialize()
HasPopup = GetIfNotFalse(properties.GetValue("haspopup")?.ToObject<string>()),
Invalid = GetIfNotFalse(properties.GetValue("invalid")?.ToObject<string>()),
Orientation = GetIfNotFalse(properties.GetValue("orientation")?.ToObject<string>()),
Live = GetIfNotFalse(properties.GetValue("live")?.ToObject<string>()),
Relevant = GetIfNotFalse(properties.GetValue("relevant")?.ToObject<string>()),
Errormessage = GetIfNotFalse(properties.GetValue("errormessage")?.ToObject<string>()),
Details = GetIfNotFalse(properties.GetValue("details")?.ToObject<string>()),
};

return node;
}

private static bool GetBooleanProperty(AccessibilityGetFullAXTreeResponse.AXTreeNode payload, string propertyName)
{
var prop = payload.Properties?.FirstOrDefault(p => p.Name == propertyName);
if (prop == null)
{
return false;
}

var element = prop.Value.Value;
return element.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.Number => element.GetInt32() != 0,
_ => false,
};
}

private static bool GetBooleanPropertyValue(Dictionary<string, JsonElement?> properties, string key)
{
var element = properties.GetValue(key);
if (element == null)
{
return false;
}

return element.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.Number => element.Value.GetInt32() != 0,
_ => false,
};
}

private bool IsPlainTextField()
=> !_richlyEditable && (_editable || _role == "textbox" || _role == "ComboBox" || _role == "searchbox");

Expand Down
42 changes: 42 additions & 0 deletions lib/PuppeteerSharp/PageAccessibility/SerializedAXNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ public class SerializedAXNode : IEquatable<SerializedAXNode>
/// </summary>
public string Orientation { get; set; }

/// <summary>
/// Whether the node is <see href="https://www.w3.org/TR/wai-aria/#aria-busy">busy</see>.
/// </summary>
public bool Busy { get; set; }

/// <summary>
/// The <see href="https://www.w3.org/TR/wai-aria/#aria-live">live</see> status of the node.
/// </summary>
public string Live { get; set; }

/// <summary>
/// Whether the live region is <see href="https://www.w3.org/TR/wai-aria/#aria-atomic">atomic</see>.
/// </summary>
public bool Atomic { get; set; }

/// <summary>
/// The <see href="https://www.w3.org/TR/wai-aria/#aria-relevant">relevant</see> changes for the live region.
/// </summary>
public string Relevant { get; set; }

/// <summary>
/// The <see href="https://www.w3.org/TR/wai-aria/#aria-errormessage">error message</see> for the node.
/// </summary>
public string Errormessage { get; set; }

/// <summary>
/// The <see href="https://www.w3.org/TR/wai-aria/#aria-details">details</see> for the node.
/// </summary>
public string Details { get; set; }

/// <summary>
/// Child nodes of this node, if any.
/// </summary>
Expand All @@ -158,6 +188,12 @@ public bool Equals(SerializedAXNode other)
AutoComplete == other.AutoComplete &&
HasPopup == other.HasPopup &&
Orientation == other.Orientation &&
Busy == other.Busy &&
Live == other.Live &&
Atomic == other.Atomic &&
Relevant == other.Relevant &&
Errormessage == other.Errormessage &&
Details == other.Details &&
Disabled == other.Disabled &&
Expanded == other.Expanded &&
Focused == other.Focused &&
Expand Down Expand Up @@ -189,6 +225,12 @@ public override int GetHashCode()
AutoComplete.GetHashCode() ^
HasPopup.GetHashCode() ^
Orientation.GetHashCode() ^
Busy.GetHashCode() ^
Live.GetHashCode() ^
Atomic.GetHashCode() ^
Relevant.GetHashCode() ^
Errormessage.GetHashCode() ^
Details.GetHashCode() ^
Disabled.GetHashCode() ^
Expanded.GetHashCode() ^
Focused.GetHashCode() ^
Expand Down
Loading