blob: 6b15498b64bcebf2b155232cf81cbfb47e28fa1c [file]
// <copyright file="AccessibilitySnapshotTests.cs" company="Selenium Committers">
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// </copyright>
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace OpenQA.Selenium.Tests;
/// <summary>
/// driver.GetAccessibilitySnapshotAsync() and element.GetAccessibilitySnapshotAsync() over BiDi.
/// The shared corpus in common/src/web/accessibility is the cross-binding contract: every page is
/// snapshotted whole and compared with its recorded tree and text. The fixture's one driver is
/// created with the WebSocket URL, since the snapshot runs over BiDi.
/// </summary>
[TestFixture]
public class AccessibilitySnapshotTests : DriverTestFixture
{
/// <summary>
/// The atom records frames as leaves; the binding fills them in, so this page has its own tests.
/// </summary>
private const string FramesPage = "014-iframe";
/// <summary>
/// NUnit discovers test cases synchronously, so the case list is the one corpus read that cannot
/// await; it uses the client's synchronous Send rather than blocking on a task.
/// </summary>
public static IEnumerable<string> ListCorpusPages()
{
using var client = new HttpClient();
using HttpResponseMessage response = client.Send(new HttpRequestMessage(HttpMethod.Get, Urls.WhereIs("accessibility/index.json")));
response.EnsureSuccessStatusCode();
using JsonDocument index = JsonDocument.Parse(response.Content.ReadAsStream());
foreach (JsonElement page in index.RootElement.EnumerateArray())
{
string name = page.GetString()!;
if (name != FramesPage)
{
yield return name;
}
}
}
protected override void ConfigureDriverOptions(DriverOptions options)
{
options.UseWebSocketUrl = true;
if (options is Safari.SafariOptions safariOptions)
{
safariOptions.AddAdditionalOption("safari:experimentalWebSocketUrl", true);
}
}
[TestCaseSource(nameof(ListCorpusPages))]
public async Task WholePageSnapshotMatchesTheCorpus(string page)
{
Load(page);
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
JsonNode actual = JsonNode.Parse(Serialize(tree).ToJsonString())!;
JsonNode expected = JsonNode.Parse(await ReadExpectedAsync(page, "json"))!;
Assert.That(JsonNode.DeepEquals(actual, expected), Is.True, () => "snapshot differs from the corpus:\n" + actual.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
Assert.That(tree.ToText() + "\n", Is.EqualTo(await ReadExpectedAsync(page, "txt")));
}
[TestCaseSource(nameof(ListCorpusPages))]
public async Task UnprunedSnapshotMatchesTheCorpus(string page)
{
Load(page);
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync(interestingOnly: false);
JsonNode actual = JsonNode.Parse(Serialize(tree).ToJsonString())!;
JsonNode expected = JsonNode.Parse(await ReadExpectedAsync(page, "json", ".full"))!;
Assert.That(JsonNode.DeepEquals(actual, expected), Is.True, () => "unpruned snapshot differs from the corpus:\n" + actual.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
Assert.That(tree.ToText() + "\n", Is.EqualTo(await ReadExpectedAsync(page, "txt", ".full")));
}
[Test]
public async Task ALinkCarriesItsResolvedUrl()
{
Load("003-links-and-buttons");
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
AccessibilityNode link = Walk(tree).First(node => node.Role == "link");
Assert.That(link.Url, Is.EqualTo(link.Element!.GetDomProperty("href")));
Assert.That(link.Url, Does.StartWith("https://example.com/"));
Assert.That(Walk(tree).Where(node => node.Role != "link").Select(node => node.Url), Is.All.Null);
}
[Test]
public async Task ASubtreeSnapshotCanBeUnprunedToo()
{
Load("010-generic-and-presentation");
WebElement wrapper = (WebElement)Driver.FindElement(By.Id("wrap-1"));
AccessibilityNode pruned = await wrapper.GetAccessibilitySnapshotAsync();
AccessibilityNode full = await wrapper.GetAccessibilitySnapshotAsync(interestingOnly: false);
Assert.That(ListRoles(pruned.Children), Is.EqualTo(new[] { "heading", "text" }));
Assert.That(ListRoles(full.Children), Is.EqualTo(new[] { "generic" }));
Assert.That(full.Children[0].Element!.GetDomAttribute("id"), Is.EqualTo("wrap-2"));
}
[Test]
public async Task WholePageSnapshotIsRootedAtTheDocument()
{
Load("001-headings-and-landmarks");
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
Assert.That(tree.Role, Is.EqualTo("document"));
Assert.That(tree.Element, Is.Not.Null);
Assert.That(tree.Element!.TagName, Is.EqualTo("html"));
}
[Test]
public async Task ElementReferencesAreUsable()
{
Load("003-links-and-buttons");
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
AccessibilityNode button = Walk(tree).First(node => node.Role == "button");
Assert.That(button.Element!.TagName, Is.AnyOf("button", "input", "a"));
Assert.That(button.Element.GetDomAttribute("id"), Is.Not.Empty);
}
[Test]
public async Task SubtreeSnapshotAlwaysProducesANodeForItsRoot()
{
Load("005-lists");
WebElement root = (WebElement)Driver.FindElement(By.TagName("body"));
AccessibilityNode tree = await root.GetAccessibilitySnapshotAsync();
Assert.That(tree.Role, Is.EqualTo("generic"));
Assert.That(tree.Element!.TagName, Is.EqualTo("body"));
Assert.That(Walk(tree).Any(node => node.Role == "list"), Is.True);
}
[Test]
public async Task SubtreeSnapshotOfANodeFromAnEarlierSnapshot()
{
Load("005-lists");
AccessibilityNode firstList = Walk(await BiDiDriver.GetAccessibilitySnapshotAsync()).First(node => node.Role == "list");
AccessibilityNode tree = await ((WebElement)firstList.Element!).GetAccessibilitySnapshotAsync();
Assert.That(tree.Role, Is.EqualTo("list"));
Assert.That(ListRoles(tree.Children), Is.EqualTo(ListRoles(firstList.Children)));
}
[Test]
public async Task FramesAreStitchedIn()
{
Load(FramesPage);
AccessibilityNode tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
List<AccessibilityNode> frames = Walk(tree).Where(node => node.Role == "iframe").ToList();
Assert.That(frames.Select(frame => frame.Name), Is.EqualTo(new[] { "Embedded content", "" }));
foreach (AccessibilityNode frame in frames)
{
Assert.That(frame.Children, Has.Count.EqualTo(1), "a reachable frame carries its document as the one child");
Assert.That(frame.Children[0].Role, Is.EqualTo("document"));
Assert.That(frame.Children[0].Element, Is.Not.Null);
}
// The text form renders the stitched tree; the atom-level expectation stops at the leaves.
Assert.That(tree.ToText().Split("- document"), Has.Length.EqualTo(4));
// A reference inside a frame works through classic commands once that frame is current.
Driver.SwitchTo().Frame(frames[0].Element!);
try
{
Assert.That(frames[0].Children[0].Element!.TagName, Is.EqualTo("html"));
}
finally
{
Driver.SwitchTo().DefaultContent();
}
}
[Test]
public async Task SubtreeSnapshotOfAnIframeElementCarriesItsDocument()
{
Load(FramesPage);
WebElement frame = (WebElement)Driver.FindElement(By.Id("frame"));
AccessibilityNode tree = await frame.GetAccessibilitySnapshotAsync();
Assert.That(tree.Role, Is.EqualTo("iframe"));
Assert.That(tree.Name, Is.EqualTo("Embedded content"));
Assert.That(ListRoles(tree.Children), Is.EqualTo(new[] { "document" }));
}
[Test]
public async Task SubtreeSnapshotInsideAFrame()
{
Load(FramesPage);
Driver.SwitchTo().Frame("frame");
try
{
WebElement body = (WebElement)Driver.FindElement(By.TagName("body"));
AccessibilityNode tree = await body.GetAccessibilitySnapshotAsync();
// The root is the frame's body, not the top document that owns the window.
Assert.That(tree.Role, Is.EqualTo("generic"));
Assert.That(tree.Name, Is.Empty);
Assert.That(tree.Element!.TagName, Is.EqualTo("body"));
}
finally
{
Driver.SwitchTo().DefaultContent();
}
}
[Test]
public async Task WholePageSnapshotIgnoresTheFrameTheDriverSwitchedTo()
{
Load(FramesPage);
Driver.SwitchTo().Frame("frame");
AccessibilityNode tree;
try
{
tree = await BiDiDriver.GetAccessibilitySnapshotAsync();
}
finally
{
Driver.SwitchTo().DefaultContent();
}
Assert.That(tree.Name, Is.EqualTo("Frames"));
}
[Test]
public void AStaleRootRaisesTheStaleElementError()
{
Load("005-lists");
WebElement root = (WebElement)Driver.FindElement(By.TagName("body"));
Load("001-headings-and-landmarks");
Assert.That(async () => await root.GetAccessibilitySnapshotAsync(), Throws.InstanceOf<StaleElementReferenceException>());
}
[Test]
public void ADetachedRootRaisesTheStaleElementError()
{
Load("005-lists");
WebElement item = (WebElement)Driver.FindElement(By.TagName("li"));
((IJavaScriptExecutor)Driver).ExecuteScript("arguments[0].remove()", item);
Assert.That(async () => await item.GetAccessibilitySnapshotAsync(), Throws.InstanceOf<StaleElementReferenceException>());
}
[Test]
public async Task ASnapshotIsACopy()
{
Load("005-lists");
AccessibilityNode before = await BiDiDriver.GetAccessibilitySnapshotAsync();
((IJavaScriptExecutor)Driver).ExecuteScript("document.querySelector('li').textContent = 'changed'");
AccessibilityNode after = await BiDiDriver.GetAccessibilitySnapshotAsync();
Assert.That(after.ToText(), Is.Not.EqualTo(before.ToText()));
Assert.That(after.ToText(), Does.Contain("changed"));
}
/// <summary>
/// The fixture's driver as the class that exposes the snapshot API.
/// </summary>
private WebDriver BiDiDriver => (WebDriver)Driver;
/// <summary>
/// Loads a corpus page from the test web server.
/// </summary>
private void Load(string page)
{
Driver.Url = Urls.WhereIs("accessibility/" + page + ".html");
}
/// <summary>
/// The corpus name of the engine under test, which picks the engine-specific expectation when one is recorded.
/// </summary>
private string DetectEngine()
{
string name = (((IHasCapabilities)Driver).Capabilities.GetCapability("browserName")?.ToString() ?? string.Empty).ToLowerInvariant();
if (name.Contains("firefox"))
{
return "firefox";
}
if (name.Contains("safari") || name.Contains("webkit"))
{
return "webkit";
}
return "chromium";
}
/// <summary>
/// The engine-specific expectation when one is recorded, else the shared one; the form is
/// ".full" for the unpruned recording.
/// </summary>
private async Task<string> ReadExpectedAsync(string page, string suffix, string form = "")
{
return await FetchAsync(page + ".expected" + form + "." + DetectEngine() + "." + suffix) ?? await ReadAsync(page + ".expected" + form + "." + suffix);
}
/// <summary>
/// The corpus JSON form: every field written, `element` as the element's id.
/// </summary>
private static JsonObject Serialize(AccessibilityNode node)
{
var states = new JsonObject();
foreach (KeyValuePair<string, object> state in node.States)
{
states[state.Key] = state.Value switch
{
bool flag => JsonValue.Create(flag),
long number => JsonValue.Create(number),
_ => JsonValue.Create(state.Value.ToString()),
};
}
var json = new JsonObject
{
["role"] = node.Role,
["name"] = node.Name,
["description"] = node.Description,
};
if (node.Value is not null)
{
json["value"] = node.Value;
}
if (node.Url is not null)
{
json["url"] = node.Url;
}
json["states"] = states;
if (node.Element is not null)
{
json["element"] = node.Element.GetDomAttribute("id");
}
var children = new JsonArray();
foreach (AccessibilityNode child in node.Children)
{
children.Add(Serialize(child));
}
json["children"] = children;
return json;
}
/// <summary>
/// The node and every node below it, depth-first.
/// </summary>
private static IEnumerable<AccessibilityNode> Walk(AccessibilityNode node)
{
yield return node;
foreach (AccessibilityNode child in node.Children)
{
foreach (AccessibilityNode descendant in Walk(child))
{
yield return descendant;
}
}
}
/// <summary>
/// The roles of the nodes, in order.
/// </summary>
private static List<string> ListRoles(IEnumerable<AccessibilityNode> nodes)
{
return nodes.Select(node => node.Role).ToList();
}
/// <summary>
/// A corpus file the server must have.
/// </summary>
private static async Task<string> ReadAsync(string file)
{
return await FetchAsync(file) ?? throw new InvalidOperationException("the corpus has no " + file);
}
/// <summary>
/// The corpus is read from the test web server, as the pages are, so nothing is read from the
/// source tree; null when the server has no such file. A client per read keeps the fixture free
/// of anything to dispose.
/// </summary>
private static async Task<string> FetchAsync(string file)
{
using var client = new HttpClient();
using HttpResponseMessage response = await client.GetAsync(Urls.WhereIs("accessibility/" + file));
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return Encoding.UTF8.GetString(await response.Content.ReadAsByteArrayAsync());
}
}