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