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());
+ }
+}
diff --git a/java/src/org/openqa/selenium/AccessibilityNode.java b/java/src/org/openqa/selenium/AccessibilityNode.java
new file mode 100644
index 0000000000000..454a7922d0f4c
--- /dev/null
+++ b/java/src/org/openqa/selenium/AccessibilityNode.java
@@ -0,0 +1,199 @@
+// 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.
+
+package org.openqa.selenium;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.Nullable;
+import org.openqa.selenium.internal.Require;
+
+/**
+ * One node of an accessibility snapshot, as returned by {@link
+ * WebDriver#getAccessibilitySnapshot()} and {@link WebElement#getAccessibilitySnapshot()}.
+ *
+ * The tree is computed in the page from the DOM following WAI-ARIA, html-aam and accname. It is
+ * not the browser's own accessibility tree and can differ from it at the edges.
+ */
+public final class AccessibilityNode {
+
+ /** The order the text form writes states in. */
+ private static final List STATE_ORDER =
+ List.of("checked", "pressed", "expanded", "selected", "level", "disabled", "focused");
+
+ private final String role;
+ private final String name;
+ private final String description;
+ private final @Nullable String value;
+ private final Map states;
+ private final @Nullable WebElement element;
+ private final List children;
+
+ /**
+ * @param role the WAI-ARIA role token; {@code text} for text leaves and {@code iframe} for frames
+ * @param name the computed accessible name, {@code ""} when there is none
+ * @param description the computed accessible description, {@code ""} when there is none
+ * @param value the current value for textbox, searchbox, combobox and range roles, or {@code
+ * null}
+ * @param states the states that apply, keyed by name; values are {@link Boolean}, {@link Long} or
+ * {@link String}
+ * @param element the element the node is backed by, or {@code null} for a text leaf
+ * @param children the child nodes
+ */
+ public AccessibilityNode(
+ String role,
+ String name,
+ String description,
+ @Nullable String value,
+ Map states,
+ @Nullable WebElement element,
+ List children) {
+ this.role = Require.nonNull("Role", role);
+ this.name = Require.nonNull("Name", name);
+ this.description = Require.nonNull("Description", description);
+ this.value = value;
+ this.states =
+ Collections.unmodifiableMap(new LinkedHashMap<>(Require.nonNull("States", states)));
+ this.element = element;
+ this.children =
+ Collections.unmodifiableList(new ArrayList<>(Require.nonNull("Children", children)));
+ }
+
+ /**
+ * @return the WAI-ARIA role token; {@code text} for text leaves and {@code iframe} for frames,
+ * under which the frame's own tree is attached
+ */
+ public String getRole() {
+ return role;
+ }
+
+ /**
+ * @return the computed accessible name, {@code ""} when there is none
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return the computed accessible description, {@code ""} when there is none
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * @return the current value for textbox, searchbox, combobox and range roles; {@code null} where
+ * the role has none. A password field's value is masked.
+ */
+ public @Nullable String getValue() {
+ return value;
+ }
+
+ /**
+ * @return {@code checked}, {@code pressed}, {@code expanded}, {@code selected}, {@code level},
+ * {@code disabled} and {@code focused}, each present only when it applies, as {@link
+ * Boolean}, {@link Long} or {@link String} values. New keys may be added in any release, so
+ * ignore keys you do not recognise.
+ */
+ public Map getStates() {
+ return states;
+ }
+
+ /**
+ * @return the element the node is backed by, usable for further commands, or {@code null} for a
+ * text leaf. An element inside a child frame can be used through classic commands only once
+ * the driver has switched to that frame.
+ */
+ public @Nullable WebElement getElement() {
+ return element;
+ }
+
+ /**
+ * @return the child nodes, empty for a leaf
+ */
+ public List getChildren() {
+ return children;
+ }
+
+ /**
+ * Renders the tree as text, one node per line.
+ *
+ * The line grammar is {@code - role "name" [state] [state=value]: value}, children indented
+ * two spaces, text leaves written as {@code - text: content}. An empty name is left out, as is an
+ * absent or empty value; a {@code true} state is written bare, a {@code false} one left out, any
+ * other value spelled. A line break or backslash in a value is escaped so a node never spans two
+ * lines. The description and element references are not rendered.
+ *
+ * @return the text form, without a trailing newline
+ */
+ public String toText() {
+ StringBuilder out = new StringBuilder();
+ appendLines("", out);
+ return out.toString();
+ }
+
+ private void appendLines(String indent, StringBuilder out) {
+ if (out.length() > 0) {
+ out.append('\n');
+ }
+ out.append(indent).append(line());
+ for (AccessibilityNode child : children) {
+ child.appendLines(indent + " ", out);
+ }
+ }
+
+ private String line() {
+ if ("text".equals(role)) {
+ return "- text: " + name;
+ }
+ StringBuilder out = new StringBuilder("- ").append(role);
+ if (!name.isEmpty()) {
+ out.append(' ').append(quote(name));
+ }
+ for (String key : STATE_ORDER) {
+ if (!states.containsKey(key)) {
+ continue;
+ }
+ Object state = states.get(key);
+ if (Boolean.TRUE.equals(state)) {
+ out.append(" [").append(key).append(']');
+ } else if (!Boolean.FALSE.equals(state)) {
+ out.append(" [").append(key).append('=').append(state).append(']');
+ }
+ }
+ if (value != null && !value.isEmpty()) {
+ out.append(": ").append(oneLine(value));
+ }
+ return out.toString();
+ }
+
+ private static String quote(String text) {
+ return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+
+ private static String oneLine(String text) {
+ return text.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n");
+ }
+
+ @Override
+ public String toString() {
+ return "AccessibilityNode{role=" + role + ", name=" + quote(name) + "}";
+ }
+}
diff --git a/java/src/org/openqa/selenium/WebDriver.java b/java/src/org/openqa/selenium/WebDriver.java
index 3af098ba13e7f..bb2f7494be05d 100644
--- a/java/src/org/openqa/selenium/WebDriver.java
+++ b/java/src/org/openqa/selenium/WebDriver.java
@@ -202,6 +202,22 @@ public interface WebDriver extends SearchContext {
*/
Options manage();
+ /**
+ * Takes an accessibility snapshot of the whole page: the tree of roles, names, states and values
+ * computed from the DOM, rooted at the document of the current window's top-level browsing
+ * context whatever frame the driver has switched to, with every reachable child frame's tree
+ * attached under its {@code iframe} node. The snapshot is a copy taken at one point in time.
+ *
+ *
Requires a WebDriver BiDi session.
+ *
+ * @return the root node of the snapshot, of role {@code document}
+ * @throws UnsupportedOperationException if the driver does not support snapshots
+ * @see AccessibilityNode#toText()
+ */
+ default AccessibilityNode getAccessibilitySnapshot() {
+ throw new UnsupportedOperationException("getAccessibilitySnapshot");
+ }
+
/** An interface for managing stuff you would do in a browser menu */
interface Options {
diff --git a/java/src/org/openqa/selenium/WebElement.java b/java/src/org/openqa/selenium/WebElement.java
index 08ad993596eb5..ec69d7d58e1c2 100644
--- a/java/src/org/openqa/selenium/WebElement.java
+++ b/java/src/org/openqa/selenium/WebElement.java
@@ -191,6 +191,20 @@ public interface WebElement extends SearchContext, TakesScreenshot {
throw new UnsupportedOperationException("getAccessibleName");
}
+ /**
+ * Takes an accessibility snapshot of the subtree under this element. The element always produces
+ * the root node; an {@code iframe} carries its document.
+ *
+ *
Requires a WebDriver BiDi session.
+ *
+ * @return the node for this element, with its subtree
+ * @throws UnsupportedOperationException if the driver does not support snapshots
+ * @see WebDriver#getAccessibilitySnapshot()
+ */
+ default AccessibilityNode getAccessibilitySnapshot() {
+ throw new UnsupportedOperationException("getAccessibilitySnapshot");
+ }
+
/**
* Determine whether this element is selected or not. This operation only applies to input
* elements such as checkboxes, options in a select and radio buttons. For more information on
diff --git a/java/src/org/openqa/selenium/remote/AccessibilitySnapshotter.java b/java/src/org/openqa/selenium/remote/AccessibilitySnapshotter.java
new file mode 100644
index 0000000000000..8cf0e9bf66ef3
--- /dev/null
+++ b/java/src/org/openqa/selenium/remote/AccessibilitySnapshotter.java
@@ -0,0 +1,363 @@
+// 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.
+
+package org.openqa.selenium.remote;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.logging.Logger;
+import org.jspecify.annotations.Nullable;
+import org.openqa.selenium.AccessibilityNode;
+import org.openqa.selenium.StaleElementReferenceException;
+import org.openqa.selenium.WebDriverException;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.bidi.BiDi;
+import org.openqa.selenium.bidi.Command;
+import org.openqa.selenium.bidi.module.Script;
+import org.openqa.selenium.bidi.script.CallFunctionParameters;
+import org.openqa.selenium.bidi.script.ContextTarget;
+import org.openqa.selenium.bidi.script.EvaluateResult;
+import org.openqa.selenium.bidi.script.EvaluateResultExceptionValue;
+import org.openqa.selenium.bidi.script.EvaluateResultSuccess;
+import org.openqa.selenium.bidi.script.LocalValue;
+import org.openqa.selenium.bidi.script.RemoteReference;
+import org.openqa.selenium.bidi.script.RemoteValue;
+import org.openqa.selenium.bidi.script.ResultOwnership;
+import org.openqa.selenium.bidi.script.SerializationOptions;
+import org.openqa.selenium.bidi.script.WindowProxyProperties;
+import org.openqa.selenium.io.Read;
+import org.openqa.selenium.json.Json;
+
+/**
+ * Takes accessibility snapshots for one BiDi session: runs the atom's wire entry in the sandbox
+ * realm of each document and stitches child frames into the tree. The atom hands the tree back as
+ * one JSON string beside the element and frame references, so this class only maps references and
+ * recurses into frames.
+ */
+class AccessibilitySnapshotter {
+
+ private static final Logger LOG = Logger.getLogger(AccessibilitySnapshotter.class.getName());
+
+ /** The sandbox realm the atom runs in: page scripts cannot reach its prototypes. */
+ private static final String SANDBOX = "selenium";
+
+ private static final String STALE_MESSAGE =
+ "the snapshot root is no longer attached to a document";
+
+ private static final Json JSON = new Json();
+
+ private static volatile @Nullable String atomSource;
+
+ private final RemoteWebDriver driver;
+ private final BiDi bidi;
+ private final Script script;
+
+ @SuppressWarnings("deprecation")
+ AccessibilitySnapshotter(RemoteWebDriver driver) {
+ this.driver = driver;
+ this.bidi = driver.getBiDi();
+ this.script = new Script(driver);
+ }
+
+ /**
+ * Snapshots the whole page, or the subtree under {@code root}.
+ *
+ * @param root the element whose subtree to snapshot, or {@code null} for the page of the current
+ * window's top-level browsing context
+ * @return the snapshot, with every reachable child frame's tree attached under its {@code iframe}
+ * node
+ * @throws StaleElementReferenceException when {@code root} no longer belongs to a document
+ */
+ AccessibilityNode snapshot(@Nullable RemoteWebElement root) {
+ String top = driver.getWindowHandle();
+ if (root == null) {
+ AccessibilityNode node = snapshotContext(top, null);
+ if (node == null) {
+ throw new WebDriverException("accessibility snapshot: the script returned nothing");
+ }
+ return node;
+ }
+ // The element may live in any frame of the window. Chromium resolves its shared id only in
+ // the document that owns it; Firefox resolves it in any document of the browsing context
+ // group, so the atom checks ownership itself and each context is tried until the owner is
+ // found.
+ for (String context : contexts(top)) {
+ try {
+ AccessibilityNode node = snapshotContext(context, root.getId());
+ if (node != null) {
+ return node;
+ }
+ } catch (NotOwned ignored) {
+ // Try the next context.
+ }
+ }
+ throw new StaleElementReferenceException(STALE_MESSAGE);
+ }
+
+ /**
+ * Builds the node tree from the atom's wire form.
+ *
+ * @param data the parsed JSON of the tree: the node's fields, with {@code element} an index into
+ * {@code elements} and, on a frame node, {@code frame} an index the binding resolves to the
+ * frame's own tree
+ * @param elements the elements the tree references, in the atom's order
+ * @param frameChildren the children of a frame node, from its {@code frame} index: the frame's
+ * document, or nothing when it cannot be reached
+ * @return the node, with its subtree
+ */
+ @SuppressWarnings("unchecked")
+ static AccessibilityNode fromWire(
+ Map data,
+ List extends WebElement> elements,
+ Function> frameChildren) {
+ List children = new ArrayList<>();
+ if (data.containsKey("frame")) {
+ children.addAll(frameChildren.apply(index(data.get("frame"))));
+ } else {
+ for (Object child : (List) data.get("children")) {
+ children.add(fromWire((Map) child, elements, frameChildren));
+ }
+ }
+ Map states = new LinkedHashMap<>((Map) data.get("states"));
+ Object element = data.get("element");
+ Object description = data.get("description");
+ Object value = data.get("value");
+ return new AccessibilityNode(
+ (String) data.get("role"),
+ (String) data.get("name"),
+ description == null ? "" : (String) description,
+ value == null ? null : (String) value,
+ states,
+ element == null ? null : elements.get(index(element)),
+ children);
+ }
+
+ private static int index(Object value) {
+ return ((Number) value).intValue();
+ }
+
+ /** The snapshot of {@code context}, or {@code null} when another document owns the root. */
+ private @Nullable AccessibilityNode snapshotContext(String context, @Nullable String rootId) {
+ List arguments =
+ rootId == null
+ ? List.of()
+ : List.of(new RemoteReference(RemoteReference.Type.SHARED_ID, rootId));
+ RemoteValue result = call(context, arguments);
+ if ("null".equals(result.getType())) {
+ return null;
+ }
+ Map fields = objectFields(result);
+ if (fields.containsKey("error")) {
+ // The only error the atom reports as data: the root left its document.
+ throw new StaleElementReferenceException(STALE_MESSAGE);
+ }
+ List elements = new ArrayList<>();
+ for (RemoteValue node : items(required(fields, "elements"))) {
+ elements.add(element(sharedId(node)));
+ }
+ List<@Nullable String> windows = new ArrayList<>();
+ for (RemoteValue window : items(required(fields, "windows"))) {
+ windows.add(contextOf(window));
+ }
+ Map data = JSON.toType(string(required(fields, "json")), Json.MAP_TYPE);
+ return fromWire(data, elements, index -> stitch(windows.get(index)));
+ }
+
+ /**
+ * Runs the atom's wire entry in the sandbox of {@code context}; the remote value of its result.
+ */
+ private RemoteValue call(String context, List arguments) {
+ SerializationOptions options = new SerializationOptions();
+ options.setMaxDomDepth(0);
+ EvaluateResult result;
+ try {
+ result =
+ script.callFunction(
+ new CallFunctionParameters(
+ new ContextTarget(context, SANDBOX),
+ "(root) => (" + atom() + ")(root)",
+ false)
+ .arguments(arguments)
+ .resultOwnership(ResultOwnership.NONE)
+ .serializationOptions(options));
+ } catch (WebDriverException e) {
+ // The BiDi "no such node" error: the document of this context does not own the root's
+ // shared id, either because the root lives in another frame or because its document is gone.
+ if (mentions(e, "no such node")) {
+ throw new NotOwned(e);
+ }
+ throw e;
+ }
+ if (result.getResultType() == EvaluateResult.Type.SUCCESS) {
+ return ((EvaluateResultSuccess) result).getResult();
+ }
+ String text = ((EvaluateResultExceptionValue) result).getExceptionDetails().getText();
+ throw new WebDriverException("accessibility snapshot failed in the page: " + text);
+ }
+
+ /** The document of a frame as the frame node's one child, or no children when unreachable. */
+ private List stitch(@Nullable String context) {
+ if (context == null) {
+ return List.of();
+ }
+ AccessibilityNode document;
+ try {
+ document = snapshotContext(context, null);
+ } catch (RuntimeException e) {
+ // Gone between discovery and the call, or mid-navigation: the frame stays a leaf. Anything
+ // else is a real failure.
+ if (!frameUnreachable(e)) {
+ throw e;
+ }
+ LOG.fine("accessibility snapshot: frame " + context + " not reachable: " + e.getMessage());
+ return List.of();
+ }
+ return document == null ? List.of() : List.of(document);
+ }
+
+ /** The window's browsing contexts, top-level first, then each frame depth-first. */
+ @SuppressWarnings("unchecked")
+ private List contexts(String top) {
+ Map tree =
+ bidi.send(new Command<>("browsingContext.getTree", Map.of("root", top), Map.class));
+ List contexts = new ArrayList<>();
+ contexts.add(top);
+ List stack = new ArrayList<>();
+ Object roots = tree.get("contexts");
+ if (roots instanceof List) {
+ stack.addAll((List) roots);
+ }
+ while (!stack.isEmpty()) {
+ Map info = (Map) stack.remove(0);
+ String id = String.valueOf(info.get("context"));
+ if (!top.equals(id)) {
+ contexts.add(id);
+ }
+ Object children = info.get("children");
+ if (children instanceof List) {
+ stack.addAll(0, (List) children);
+ }
+ }
+ return contexts;
+ }
+
+ private RemoteWebElement element(String sharedId) {
+ Object converted =
+ driver.getElementConverter().apply(Map.of(Dialect.W3C.getEncodedElementKey(), sharedId));
+ return (RemoteWebElement) converted;
+ }
+
+ /** Whether an error says a frame or its document is gone, rather than that a command failed. */
+ private static boolean frameUnreachable(RuntimeException e) {
+ return e instanceof NotOwned || mentions(e, "no such frame") || mentions(e, "no such node");
+ }
+
+ private static boolean mentions(Throwable e, String text) {
+ String message = e.getMessage();
+ return message != null && message.contains(text);
+ }
+
+ private static Map objectFields(RemoteValue value) {
+ if (!"object".equals(value.getType())) {
+ throw new WebDriverException(
+ "accessibility snapshot: expected an object remote value, got " + value.getType());
+ }
+ Object raw = value.getValue().orElse(Map.of());
+ if (!(raw instanceof Map)) {
+ throw new WebDriverException("accessibility snapshot: expected object entries");
+ }
+ Map fields = new LinkedHashMap<>();
+ ((Map, ?>) raw)
+ .forEach(
+ (key, entry) -> {
+ // A property name is written as a plain string, or as a string remote value.
+ String name =
+ key instanceof RemoteValue ? string((RemoteValue) key) : String.valueOf(key);
+ fields.put(name, (RemoteValue) entry);
+ });
+ return fields;
+ }
+
+ private static List items(RemoteValue array) {
+ Object raw = array.getValue().orElse(List.of());
+ if (!(raw instanceof List)) {
+ throw new WebDriverException("accessibility snapshot: expected an array remote value");
+ }
+ List items = new ArrayList<>();
+ for (Object item : (List>) raw) {
+ items.add((RemoteValue) item);
+ }
+ return items;
+ }
+
+ private static RemoteValue required(Map fields, String name) {
+ RemoteValue value = fields.get(name);
+ if (value == null) {
+ throw new WebDriverException("accessibility snapshot: the result has no " + name);
+ }
+ return value;
+ }
+
+ private static String string(RemoteValue value) {
+ return value.getValue().map(String::valueOf).orElse("");
+ }
+
+ private static String sharedId(RemoteValue node) {
+ if (!"node".equals(node.getType()) || node.getSharedId().isEmpty()) {
+ throw new WebDriverException(
+ "accessibility snapshot: expected a node reference in the elements list");
+ }
+ return node.getSharedId().get();
+ }
+
+ /**
+ * The browsing context id of a window remote value; null for a frame with no reachable context.
+ */
+ private static @Nullable String contextOf(RemoteValue window) {
+ if (!"window".equals(window.getType())) {
+ return null;
+ }
+ Object properties = window.getValue().orElse(null);
+ if (!(properties instanceof WindowProxyProperties)) {
+ return null;
+ }
+ return ((WindowProxyProperties) properties).getBrowsingContext();
+ }
+
+ /** The atom's wire entry, a JavaScript function expression, read once from the jar. */
+ private static String atom() {
+ String source = atomSource;
+ if (source == null) {
+ source =
+ Read.resourceAsString(
+ AccessibilitySnapshotter.class,
+ "/org/openqa/selenium/remote/accessibilitySnapshot.js");
+ atomSource = source;
+ }
+ return source;
+ }
+
+ /** A shared id that the targeted document does not own. */
+ private static final class NotOwned extends RuntimeException {
+ NotOwned(Throwable cause) {
+ super(cause.getMessage(), cause);
+ }
+ }
+}
diff --git a/java/src/org/openqa/selenium/remote/BUILD.bazel b/java/src/org/openqa/selenium/remote/BUILD.bazel
index 224d404e9dd3e..bff12c66ea4c7 100644
--- a/java/src/org/openqa/selenium/remote/BUILD.bazel
+++ b/java/src/org/openqa/selenium/remote/BUILD.bazel
@@ -50,6 +50,7 @@ java_library(
name = "api",
srcs = glob(["**/*.java"]),
resources = [
+ ":accessibility-snapshot",
":bidi-mutation-listener",
":get-attribute",
":is-displayed",
@@ -83,6 +84,12 @@ java_library(
],
)
+copy_file(
+ name = "accessibility-snapshot",
+ src = "//javascript/atoms/fragments:accessibility-snapshot-wire-typescript.js",
+ out = "accessibilitySnapshot.js",
+)
+
copy_file(
name = "get-attribute",
src = "//javascript/atoms/fragments:get-attribute-typescript.js",
diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java
index 3551347ad4181..1c003d81a857a 100644
--- a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java
+++ b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java
@@ -52,6 +52,7 @@
import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
import org.openqa.selenium.AcceptedW3CCapabilityKeys;
+import org.openqa.selenium.AccessibilityNode;
import org.openqa.selenium.Alert;
import org.openqa.selenium.Beta;
import org.openqa.selenium.By;
@@ -74,6 +75,7 @@
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.TakesScreenshot;
+import org.openqa.selenium.UnsupportedCommandException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebDriverException;
import org.openqa.selenium.WebElement;
@@ -146,6 +148,7 @@ public class RemoteWebDriver
private final Logs remoteLogs = new RemoteLogs(executeMethod);
@Nullable private Script remoteScript;
+ @Nullable private AccessibilitySnapshotter accessibilitySnapshotter;
@Nullable private Network remoteNetwork;
@@ -665,6 +668,25 @@ public Script script() {
return this.remoteScript;
}
+ @Override
+ public AccessibilityNode getAccessibilitySnapshot() {
+ return accessibility().snapshot(null);
+ }
+
+ // The snapshot runs in a BiDi sandbox realm; a classic-only session cannot take one.
+ @SuppressWarnings("deprecation")
+ AccessibilitySnapshotter accessibility() {
+ if (maybeGetBiDi().isEmpty()) {
+ throw new UnsupportedCommandException(
+ "getAccessibilitySnapshot() requires a WebDriver BiDi session; enable it with"
+ + " options.enableBiDi() before starting the driver");
+ }
+ if (this.accessibilitySnapshotter == null) {
+ this.accessibilitySnapshotter = new AccessibilitySnapshotter(this);
+ }
+ return this.accessibilitySnapshotter;
+ }
+
public Network network() {
if (this.remoteNetwork == null) {
this.remoteNetwork = new RemoteNetwork(this);
diff --git a/java/src/org/openqa/selenium/remote/RemoteWebElement.java b/java/src/org/openqa/selenium/remote/RemoteWebElement.java
index 0cd2f4655f58f..db3919f642520 100644
--- a/java/src/org/openqa/selenium/remote/RemoteWebElement.java
+++ b/java/src/org/openqa/selenium/remote/RemoteWebElement.java
@@ -27,6 +27,7 @@
import java.util.Map;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
+import org.openqa.selenium.AccessibilityNode;
import org.openqa.selenium.Beta;
import org.openqa.selenium.By;
import org.openqa.selenium.Dimension;
@@ -161,6 +162,11 @@ public String getAccessibleName() {
return stringValueOf(execute(DriverCommand.GET_ELEMENT_ACCESSIBLE_NAME(id)).getValue());
}
+ @Override
+ public AccessibilityNode getAccessibilitySnapshot() {
+ return parent.accessibility().snapshot(this);
+ }
+
private static String stringValueOf(Object o) {
if (o == null) {
return null;
diff --git a/java/test/org/openqa/selenium/AccessibilityNodeTest.java b/java/test/org/openqa/selenium/AccessibilityNodeTest.java
new file mode 100644
index 0000000000000..78cb6b35a79e0
--- /dev/null
+++ b/java/test/org/openqa/selenium/AccessibilityNodeTest.java
@@ -0,0 +1,143 @@
+// 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.
+
+package org.openqa.selenium;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openqa.selenium.build.InProject;
+import org.openqa.selenium.json.Json;
+
+/**
+ * The text renderer is held to the shared corpus in common/src/web/accessibility: every
+ * expected.txt is the reference renderer's output over its expected.json, so the port must agree.
+ */
+@Tag("UnitTests")
+class AccessibilityNodeTest {
+
+ private static final Json JSON = new Json();
+
+ static Stream corpusPages() throws IOException {
+ return JSON.>toType(read("index.json"), List.class).stream();
+ }
+
+ @ParameterizedTest
+ @MethodSource("corpusPages")
+ void rendersEveryCorpusPageAsItsRecordedText(String page) throws IOException {
+ AccessibilityNode tree = fromJson(JSON.toType(read(page + ".expected.json"), Json.MAP_TYPE));
+
+ assertThat(tree.toText() + "\n").isEqualTo(read(page + ".expected.txt"));
+ }
+
+ @Test
+ void writesTheLineGrammarWithItsEscapes() {
+ Map states = new LinkedHashMap<>();
+ states.put("focused", true);
+ states.put("disabled", true);
+ states.put("level", 2L);
+ states.put("selected", false);
+ states.put("pressed", "mixed");
+ AccessibilityNode node =
+ new AccessibilityNode(
+ "textbox", "Say \"hi\" \\ bye", "", "line one\nline two\\", states, null, List.of());
+
+ assertThat(node.toText())
+ .isEqualTo(
+ "- textbox \"Say \\\"hi\\\" \\\\ bye\" [pressed=mixed] [level=2] [disabled] [focused]:"
+ + " line one\\nline two\\\\");
+ assertThat(node("list", "", List.of()).toText()).isEqualTo("- list");
+ assertThat(node("text", "plain words", List.of()).toText()).isEqualTo("- text: plain words");
+ assertThat(
+ new AccessibilityNode("textbox", "Empty", "", "", Map.of(), null, List.of()).toText())
+ .isEqualTo("- textbox \"Empty\"");
+ }
+
+ @Test
+ void indentsChildrenTwoSpacesPerLevel() {
+ AccessibilityNode tree =
+ node(
+ "list",
+ "",
+ List.of(
+ node(
+ "listitem",
+ "",
+ List.of(node("text", "one", List.of()), node("link", "more", List.of())))));
+
+ assertThat(tree.toText())
+ .isEqualTo("- list\n - listitem\n - text: one\n - link \"more\"");
+ }
+
+ @Test
+ void exposesItsFieldsAsReadOnlyViews() {
+ Map states = new LinkedHashMap<>();
+ states.put("checked", true);
+ AccessibilityNode node =
+ new AccessibilityNode("checkbox", "Agree", "Terms", null, states, null, List.of());
+ states.put("checked", false);
+
+ assertThat(node.getRole()).isEqualTo("checkbox");
+ assertThat(node.getName()).isEqualTo("Agree");
+ assertThat(node.getDescription()).isEqualTo("Terms");
+ assertThat(node.getValue()).isNull();
+ assertThat(node.getStates()).containsEntry("checked", true);
+ assertThat(node.getElement()).isNull();
+ assertThat(node.getChildren()).isEmpty();
+ }
+
+ private static AccessibilityNode node(
+ String role, String name, List children) {
+ return new AccessibilityNode(role, name, "", null, Map.of(), null, children);
+ }
+
+ /**
+ * The corpus JSON as nodes; `element` holds the recorded id, which the text form never renders.
+ */
+ @SuppressWarnings("unchecked")
+ private static AccessibilityNode fromJson(Map data) {
+ List children = new ArrayList<>();
+ for (Object child : (List) data.get("children")) {
+ children.add(fromJson((Map) child));
+ }
+ return new AccessibilityNode(
+ (String) data.get("role"),
+ (String) data.get("name"),
+ (String) data.get("description"),
+ (String) data.get("value"),
+ (Map) data.get("states"),
+ null,
+ children);
+ }
+
+ private static String read(String file) throws IOException {
+ Path path = InProject.locate("common/src/web/accessibility/" + file);
+ return Files.readString(path, StandardCharsets.UTF_8);
+ }
+}
diff --git a/java/test/org/openqa/selenium/BUILD.bazel b/java/test/org/openqa/selenium/BUILD.bazel
index b5c6d8d3397e5..e0e0fab9b3a33 100644
--- a/java/test/org/openqa/selenium/BUILD.bazel
+++ b/java/test/org/openqa/selenium/BUILD.bazel
@@ -3,6 +3,7 @@ load("//java:defs.bzl", "JUNIT5_DEPS", "java_library", "java_selenium_test_suite
load("//java:version.bzl", "TOOLS_JAVA_VERSION")
SMALL_TESTS = [
+ "AccessibilityNodeTest.java",
"ArchitectureTest.java",
"ByTest.java",
"CookieTest.java",
@@ -26,15 +27,18 @@ java_test_suite(
name = "SmallTests",
size = "small",
srcs = SMALL_TESTS,
+ data = ["//common/src/web"],
deps = [
":helpers",
"//java/src/org/openqa/selenium/json",
"//java/src/org/openqa/selenium/remote",
+ "//java/test/org/openqa/selenium/build",
"//java/test/org/openqa/selenium/netty/server:test-server",
"//java/test/org/openqa/selenium/testing:annotations",
artifact("org.assertj:assertj-core"),
artifact("com.google.guava:guava"),
artifact("org.junit.jupiter:junit-jupiter-api"),
+ artifact("org.junit.jupiter:junit-jupiter-params"),
artifact("org.mockito:mockito-core"),
artifact("org.jspecify:jspecify"),
] + JUNIT5_DEPS,
diff --git a/java/test/org/openqa/selenium/bidi/AccessibilitySnapshotTest.java b/java/test/org/openqa/selenium/bidi/AccessibilitySnapshotTest.java
new file mode 100644
index 0000000000000..ff16a19fa141f
--- /dev/null
+++ b/java/test/org/openqa/selenium/bidi/AccessibilitySnapshotTest.java
@@ -0,0 +1,290 @@
+// 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.
+
+package org.openqa.selenium.bidi;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openqa.selenium.AccessibilityNode;
+import org.openqa.selenium.By;
+import org.openqa.selenium.HasCapabilities;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.StaleElementReferenceException;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.build.InProject;
+import org.openqa.selenium.json.Json;
+import org.openqa.selenium.testing.JupiterTestBase;
+
+/**
+ * driver.getAccessibilitySnapshot() and element.getAccessibilitySnapshot() 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.
+ */
+class AccessibilitySnapshotTest extends JupiterTestBase {
+
+ private static final Json JSON = new Json();
+
+ /**
+ * The atom records frames as leaves; the binding fills them in, so this page has its own tests.
+ */
+ private static final String FRAMES_PAGE = "014-iframe";
+
+ static Stream corpusPages() throws IOException {
+ return JSON.>toType(read("index.json"), List.class).stream()
+ .filter(page -> !FRAMES_PAGE.equals(page));
+ }
+
+ @ParameterizedTest
+ @MethodSource("corpusPages")
+ void wholePageSnapshotMatchesTheCorpus(String page) throws IOException {
+ load(page);
+
+ AccessibilityNode tree = driver.getAccessibilitySnapshot();
+
+ assertThat(serialize(tree)).isEqualTo(JSON.toType(expected(page, "json"), Json.MAP_TYPE));
+ assertThat(tree.toText() + "\n").isEqualTo(expected(page, "txt"));
+ }
+
+ @Test
+ void wholePageSnapshotIsRootedAtTheDocument() {
+ load("001-headings-and-landmarks");
+
+ AccessibilityNode tree = driver.getAccessibilitySnapshot();
+
+ assertThat(tree.getRole()).isEqualTo("document");
+ assertThat(tree.getElement()).isNotNull();
+ assertThat(tree.getElement().getTagName()).isEqualTo("html");
+ }
+
+ @Test
+ void elementReferencesAreUsable() {
+ load("003-links-and-buttons");
+
+ AccessibilityNode tree = driver.getAccessibilitySnapshot();
+
+ AccessibilityNode button =
+ walk(tree).filter(node -> "button".equals(node.getRole())).findFirst().orElseThrow();
+ assertThat(button.getElement().getTagName()).isIn("button", "input", "a");
+ assertThat(button.getElement().getDomAttribute("id")).isNotEmpty();
+ }
+
+ @Test
+ void subtreeSnapshotAlwaysProducesANodeForItsRoot() {
+ load("005-lists");
+ WebElement root = driver.findElement(By.tagName("body"));
+
+ AccessibilityNode tree = root.getAccessibilitySnapshot();
+
+ assertThat(tree.getRole()).isEqualTo("generic");
+ assertThat(tree.getElement().getTagName()).isEqualTo("body");
+ assertThat(walk(tree).anyMatch(node -> "list".equals(node.getRole()))).isTrue();
+ }
+
+ @Test
+ void subtreeSnapshotOfANodeFromAnEarlierSnapshot() {
+ load("005-lists");
+ AccessibilityNode firstList =
+ walk(driver.getAccessibilitySnapshot())
+ .filter(node -> "list".equals(node.getRole()))
+ .findFirst()
+ .orElseThrow();
+
+ AccessibilityNode tree = firstList.getElement().getAccessibilitySnapshot();
+
+ assertThat(tree.getRole()).isEqualTo("list");
+ assertThat(roles(tree.getChildren())).isEqualTo(roles(firstList.getChildren()));
+ }
+
+ @Test
+ void framesAreStitchedIn() {
+ load(FRAMES_PAGE);
+
+ AccessibilityNode tree = driver.getAccessibilitySnapshot();
+
+ List frames =
+ walk(tree).filter(node -> "iframe".equals(node.getRole())).collect(Collectors.toList());
+ assertThat(frames)
+ .extracting(AccessibilityNode::getName)
+ .containsExactly("Embedded content", "");
+ for (AccessibilityNode frame : frames) {
+ assertThat(frame.getChildren())
+ .as("a reachable frame carries its document as the one child")
+ .hasSize(1);
+ assertThat(frame.getChildren().get(0).getRole()).isEqualTo("document");
+ assertThat(frame.getChildren().get(0).getElement()).isNotNull();
+ }
+ // The text form renders the stitched tree; the atom-level expectation stops at the leaves.
+ assertThat(tree.toText().split("- document", -1)).hasSize(4);
+ // A reference inside a frame works through classic commands once that frame is current.
+ driver.switchTo().frame(frames.get(0).getElement());
+ try {
+ assertThat(frames.get(0).getChildren().get(0).getElement().getTagName()).isEqualTo("html");
+ } finally {
+ driver.switchTo().defaultContent();
+ }
+ }
+
+ @Test
+ void subtreeSnapshotOfAnIframeElementCarriesItsDocument() {
+ load(FRAMES_PAGE);
+ WebElement frame = driver.findElement(By.id("frame"));
+
+ AccessibilityNode tree = frame.getAccessibilitySnapshot();
+
+ assertThat(tree.getRole()).isEqualTo("iframe");
+ assertThat(tree.getName()).isEqualTo("Embedded content");
+ assertThat(roles(tree.getChildren())).containsExactly("document");
+ }
+
+ @Test
+ void subtreeSnapshotInsideAFrame() {
+ load(FRAMES_PAGE);
+ driver.switchTo().frame("frame");
+ try {
+ WebElement body = driver.findElement(By.tagName("body"));
+ AccessibilityNode tree = body.getAccessibilitySnapshot();
+ // The root is the frame's body, not the top document that owns the window.
+ assertThat(tree.getRole()).isEqualTo("generic");
+ assertThat(tree.getName()).isEmpty();
+ assertThat(tree.getElement().getTagName()).isEqualTo("body");
+ } finally {
+ driver.switchTo().defaultContent();
+ }
+ }
+
+ @Test
+ void wholePageSnapshotIgnoresTheFrameTheDriverSwitchedTo() {
+ load(FRAMES_PAGE);
+ driver.switchTo().frame("frame");
+ AccessibilityNode tree;
+ try {
+ tree = driver.getAccessibilitySnapshot();
+ } finally {
+ driver.switchTo().defaultContent();
+ }
+
+ assertThat(tree.getName()).isEqualTo("Frames");
+ }
+
+ @Test
+ void aStaleRootRaisesTheStaleElementError() {
+ load("005-lists");
+ WebElement root = driver.findElement(By.tagName("body"));
+ load("001-headings-and-landmarks");
+
+ assertThatExceptionOfType(StaleElementReferenceException.class)
+ .isThrownBy(root::getAccessibilitySnapshot);
+ }
+
+ @Test
+ void aDetachedRootRaisesTheStaleElementError() {
+ load("005-lists");
+ WebElement item = driver.findElement(By.tagName("li"));
+ ((JavascriptExecutor) driver).executeScript("arguments[0].remove()", item);
+
+ assertThatExceptionOfType(StaleElementReferenceException.class)
+ .isThrownBy(item::getAccessibilitySnapshot);
+ }
+
+ @Test
+ void aSnapshotIsACopy() {
+ load("005-lists");
+ AccessibilityNode before = driver.getAccessibilitySnapshot();
+ ((JavascriptExecutor) driver)
+ .executeScript("document.querySelector('li').textContent = 'changed'");
+
+ AccessibilityNode after = driver.getAccessibilitySnapshot();
+
+ assertThat(before.toText()).isNotEqualTo(after.toText());
+ assertThat(after.toText()).contains("changed");
+ }
+
+ private void load(String page) {
+ driver.get(appServer.whereIs("accessibility/" + page + ".html"));
+ }
+
+ private String engine() {
+ String name = ((HasCapabilities) driver).getCapabilities().getBrowserName().toLowerCase();
+ if (name.contains("firefox")) {
+ return "firefox";
+ }
+ if (name.contains("safari") || name.contains("webkit")) {
+ return "webkit";
+ }
+ return "chromium";
+ }
+
+ /** The engine-specific expectation when one is recorded, else the shared one. */
+ private String expected(String page, String suffix) throws IOException {
+ Path specific =
+ InProject.locate("common/src/web/accessibility")
+ .resolve(page + ".expected." + engine() + "." + suffix);
+ if (Files.exists(specific)) {
+ return Files.readString(specific, StandardCharsets.UTF_8);
+ }
+ return read(page + ".expected." + suffix);
+ }
+
+ /** The corpus JSON form: every field written, `element` as the element's id. */
+ private static Map serialize(AccessibilityNode node) {
+ Map out = new LinkedHashMap<>();
+ out.put("role", node.getRole());
+ out.put("name", node.getName());
+ out.put("description", node.getDescription());
+ if (node.getValue() != null) {
+ out.put("value", node.getValue());
+ }
+ out.put("states", node.getStates());
+ if (node.getElement() != null) {
+ out.put("element", node.getElement().getDomAttribute("id"));
+ }
+ List children = new ArrayList<>();
+ for (AccessibilityNode child : node.getChildren()) {
+ children.add(serialize(child));
+ }
+ out.put("children", children);
+ return out;
+ }
+
+ private static Stream walk(AccessibilityNode node) {
+ return Stream.concat(
+ Stream.of(node), node.getChildren().stream().flatMap(AccessibilitySnapshotTest::walk));
+ }
+
+ private static List roles(List nodes) {
+ return nodes.stream().map(AccessibilityNode::getRole).collect(Collectors.toList());
+ }
+
+ private static String read(String file) throws IOException {
+ Path path = InProject.locate("common/src/web/accessibility/" + file);
+ return Files.readString(path, StandardCharsets.UTF_8);
+ }
+}
diff --git a/java/test/org/openqa/selenium/bidi/BUILD.bazel b/java/test/org/openqa/selenium/bidi/BUILD.bazel
index f862413176b01..625c9492a0563 100644
--- a/java/test/org/openqa/selenium/bidi/BUILD.bazel
+++ b/java/test/org/openqa/selenium/bidi/BUILD.bazel
@@ -47,12 +47,14 @@ java_selenium_test_suite(
"//java/src/org/openqa/selenium/json",
"//java/src/org/openqa/selenium/remote",
"//java/src/org/openqa/selenium/support",
+ "//java/test/org/openqa/selenium/build",
"//java/test/org/openqa/selenium/environment",
"//java/test/org/openqa/selenium/testing:annotations",
"//java/test/org/openqa/selenium/testing:test-base",
"//java/test/org/openqa/selenium/testing/drivers",
artifact("com.google.guava:guava"),
artifact("org.junit.jupiter:junit-jupiter-api"),
+ artifact("org.junit.jupiter:junit-jupiter-params"),
artifact("org.assertj:assertj-core"),
artifact("org.jspecify:jspecify"),
] + JUNIT5_DEPS,
diff --git a/java/test/org/openqa/selenium/remote/AccessibilitySnapshotUnitTest.java b/java/test/org/openqa/selenium/remote/AccessibilitySnapshotUnitTest.java
new file mode 100644
index 0000000000000..3335a20d6eb3c
--- /dev/null
+++ b/java/test/org/openqa/selenium/remote/AccessibilitySnapshotUnitTest.java
@@ -0,0 +1,148 @@
+// 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.
+
+package org.openqa.selenium.remote;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.AccessibilityNode;
+import org.openqa.selenium.ImmutableCapabilities;
+import org.openqa.selenium.UnsupportedCommandException;
+import org.openqa.selenium.json.Json;
+
+/**
+ * A session without BiDi cannot take an accessibility snapshot, and says so before any command; the
+ * atom's wire form decodes to the node tree with its references resolved.
+ */
+@Tag("UnitTests")
+class AccessibilitySnapshotUnitTest {
+
+ private static final Json JSON = new Json();
+
+ /** Answers the new session command with the requested capabilities and records the rest. */
+ private static final class RecordingExecutor implements CommandExecutor {
+ final List commands = new ArrayList<>();
+
+ @Override
+ public Response execute(Command command) {
+ Response response = new Response(new SessionId(UUID.randomUUID()));
+ response.setState("success");
+ if (DriverCommand.NEW_SESSION.equals(command.getName())) {
+ response.setValue(Map.of("browserName", "chrome"));
+ } else {
+ commands.add(command.getName());
+ }
+ return response;
+ }
+ }
+
+ @Test
+ void aSessionWithoutBiDiRaisesForTheWholePage() {
+ RecordingExecutor executor = new RecordingExecutor();
+ RemoteWebDriver driver =
+ new RemoteWebDriver(executor, new ImmutableCapabilities("browserName", "chrome"));
+
+ assertThatExceptionOfType(UnsupportedCommandException.class)
+ .isThrownBy(driver::getAccessibilitySnapshot)
+ .withMessageContaining("BiDi");
+ assertThat(executor.commands).isEmpty();
+ }
+
+ @Test
+ void aSessionWithoutBiDiRaisesForAnElement() {
+ RecordingExecutor executor = new RecordingExecutor();
+ RemoteWebDriver driver =
+ new RemoteWebDriver(executor, new ImmutableCapabilities("browserName", "chrome"));
+ RemoteWebElement element = new RemoteWebElement();
+ element.setParent(driver);
+ element.setId("element-1");
+
+ assertThatExceptionOfType(UnsupportedCommandException.class)
+ .isThrownBy(element::getAccessibilitySnapshot)
+ .withMessageContaining("BiDi");
+ assertThat(executor.commands).isEmpty();
+ }
+
+ @Test
+ void fromWireBuildsTheTreeAndResolvesTheIndices() {
+ RemoteWebElement region = element("region-1");
+ RemoteWebElement frame = element("frame-1");
+ RemoteWebElement button = element("button-1");
+ Map data =
+ JSON.toType(
+ "{\"role\":\"region\",\"name\":\"App\",\"description\":\"\",\"states\":{},"
+ + "\"element\":0,\"children\":["
+ + "{\"role\":\"text\",\"name\":\"Welcome\",\"description\":\"\",\"states\":{},"
+ + "\"children\":[]},"
+ + "{\"role\":\"iframe\",\"name\":\"Embedded\",\"description\":\"\",\"states\":{},"
+ + "\"element\":1,\"frame\":0,\"children\":[]},"
+ + "{\"role\":\"button\",\"name\":\"Save\",\"description\":\"Saves\","
+ + "\"states\":{\"disabled\":true,\"level\":2},\"element\":2,\"children\":[]}]}",
+ Json.MAP_TYPE);
+ AccessibilityNode framed =
+ new AccessibilityNode("document", "Inner", "", null, Map.of(), null, List.of());
+
+ AccessibilityNode node =
+ AccessibilitySnapshotter.fromWire(
+ data, List.of(region, frame, button), index -> List.of(framed));
+
+ assertThat(node.getRole()).isEqualTo("region");
+ assertThat(node.getName()).isEqualTo("App");
+ assertThat(node.getElement()).isSameAs(region);
+ assertThat(node.getChildren()).hasSize(3);
+ AccessibilityNode text = node.getChildren().get(0);
+ assertThat(text.getRole()).isEqualTo("text");
+ assertThat(text.getElement()).isNull();
+ AccessibilityNode iframe = node.getChildren().get(1);
+ assertThat(iframe.getElement()).isSameAs(frame);
+ assertThat(iframe.getChildren()).containsExactly(framed);
+ AccessibilityNode save = node.getChildren().get(2);
+ assertThat(save.getElement()).isSameAs(button);
+ assertThat(save.getDescription()).isEqualTo("Saves");
+ assertThat(save.getStates()).containsEntry("disabled", true);
+ assertThat(((Number) save.getStates().get("level")).intValue()).isEqualTo(2);
+ }
+
+ @Test
+ void fromWireKeepsAbsentFieldsAbsent() {
+ Map data =
+ JSON.toType(
+ "{\"role\":\"textbox\",\"name\":\"\",\"description\":\"\",\"value\":\"a@b.c\","
+ + "\"states\":{},\"element\":0,\"children\":[]}",
+ Json.MAP_TYPE);
+
+ AccessibilityNode node =
+ AccessibilitySnapshotter.fromWire(data, List.of(element("input-1")), index -> List.of());
+
+ assertThat(node.getValue()).isEqualTo("a@b.c");
+ assertThat(node.getChildren()).isEmpty();
+ assertThat(node.getStates()).isEmpty();
+ }
+
+ private static RemoteWebElement element(String id) {
+ RemoteWebElement element = new RemoteWebElement();
+ element.setId(id);
+ return element;
+ }
+}
diff --git a/javascript/atoms/BUILD.bazel b/javascript/atoms/BUILD.bazel
index 9c095d0a96a8a..2def22e138898 100644
--- a/javascript/atoms/BUILD.bazel
+++ b/javascript/atoms/BUILD.bazel
@@ -52,7 +52,7 @@ js_test(
# and tree-shakes down to what each entry point actually imports.
ATOM_SRCS = glob([
"typescript/*.ts",
- "typescript/core/*.ts",
+ "typescript/core/**/*.ts",
])
# ── the atoms ────────────────────────────────────────────────────────────────
@@ -82,21 +82,66 @@ js_test(
],
)
+# The pure parts of the accessibility modules (the reference text renderer)
+# run in Node against the test-only bundle, with no browser.
+js_test(
+ name = "accessibility-unit-tests",
+ size = "small",
+ data = [
+ ":typescript/accessibility-snapshot.generated.js",
+ ":typescript/accessibility-snapshot-wire.generated.js",
+ ":typescript/testing/accessibility.generated.js",
+ ],
+ entry_point = "typescript/testing/accessibility-unit-tests.mjs",
+ fixed_args = [
+ "$(rootpath :typescript/testing/accessibility.generated.js)",
+ "$(rootpath :typescript/accessibility-snapshot.generated.js)",
+ "$(rootpath :typescript/accessibility-snapshot-wire.generated.js)",
+ ],
+)
+
+# The shared accessibility corpus is the cross-binding contract: every
+# expectation validates against the node schema and every text file is the
+# renderer's output over its JSON, so a removed or renamed field fails here
+# before any binding sees it.
+js_test(
+ name = "accessibility-corpus-consistent",
+ size = "small",
+ data = [
+ "typescript/accessibility-node.schema.json",
+ ":typescript/testing/accessibility.generated.js",
+ "//common/src/web:accessibility-corpus",
+ ],
+ entry_point = "typescript/testing/corpus-consistent.mjs",
+ fixed_args = [
+ "common/src/web/accessibility",
+ "$(rootpath typescript/accessibility-node.schema.json)",
+ "$(rootpath :typescript/testing/accessibility.generated.js)",
+ ],
+)
+
# ── test-only bundles ────────────────────────────────────────────────────────
# Shared modules that no atom reaches yet still need a QUnit page. Each entry
# under typescript/testing/ default-exports what its page drives. These are not
# in ATOMS, so they are never published and never reach a binding.
TEST_BUNDLES = {
+ "accessibility": "bot.accessibility.typescript.testing",
"events": "bot.events.typescript",
"keyboard": "bot.keyboard.typescript",
"mouse": "bot.mouse.typescript",
}
+# Every test-only module is a source of every test bundle: esbuild resolves
+# imports only among the sources it is given, and a bundle's entry may import
+# a sibling (the accessibility bundle imports its text renderer). Only what an
+# entry imports ends up in its bundle.
+TEST_BUNDLE_SRCS = ATOM_SRCS + glob(["typescript/testing/*.ts"])
+
[
typescript_atom(
name = "testing/%s" % bundle,
- srcs = ATOM_SRCS + ["typescript/testing/%s.ts" % bundle],
+ srcs = TEST_BUNDLE_SRCS,
global_name = global_name,
visibility = ["//visibility:private"],
)
@@ -564,5 +609,8 @@ closure_test_suite(
data = [
":atoms",
":deps",
+ # The accessibility corpus pages, loaded by
+ # test/accessibility_corpus_typescript_test.html.
+ "//common/src/web",
] + [":testing/%s-global" % bundle for bundle in TEST_BUNDLES],
)
diff --git a/javascript/atoms/README.md b/javascript/atoms/README.md
index e13d0d9381289..118ca87c5f927 100644
--- a/javascript/atoms/README.md
+++ b/javascript/atoms/README.md
@@ -47,3 +47,36 @@ stripped of it's `.html` suffix. For example:
```shell
bazel test --test_filter=shown_test --//common:headless=false javascript/atoms:closure-test-chrome
```
+
+### The accessibility snapshot corpus
+
+The `accessibility-snapshot` atom is also held to the shared corpus in
+`common/src/web/accessibility/`, which every binding runs as well. Its QUnit
+page is `test/accessibility_corpus_typescript_test.html`; opened with
+`?record=1` on the debug server it shows the expectations to record. The
+corpus README explains the file layout, and these two Node tests check it
+without a browser:
+
+```shell
+bazel test //javascript/atoms:accessibility-unit-tests //javascript/atoms:accessibility-corpus-consistent
+```
+
+### Measuring the accessibility snapshot
+
+`test/accessibility_bench.html` is not a test (the suite only runs
+`*_test.html`). Open it from the debug server to time the atom on fixed tree
+shapes and count the DOM calls it makes, so one build of the atom can be
+compared with another on the same machine:
+
+
+
+The per-element numbers and the call counts are what to compare; absolute
+times depend on the machine. Headless Chromium 141, atom at `961f924`:
+
+| shape | elements | µs / element | getComputedStyle / element | getAttribute / element |
+| --- | --- | --- | --- | --- |
+| list 8000 | 32,001 | 8 | 1.8 | 10.8 |
+| table 1000x10 | 11,002 | 27 | 6.7 | 22.5 |
+| headings 4000 | 12,000 | 17 | 5.7 | 17.7 |
+| document-like 1000 | 12,000 | 15 | 2.5 | 14.7 |
+| fan-in 2000 | 4,003 | 28 | 8.5 | 24.5 |
diff --git a/javascript/atoms/README.npm.md b/javascript/atoms/README.npm.md
index 20982538407d6..13e37b7d8712c 100644
--- a/javascript/atoms/README.npm.md
+++ b/javascript/atoms/README.npm.md
@@ -50,6 +50,53 @@ findElements({ css: '.result' })
findElements({ 'link text': 'Continue' }, shadowRoot)
```
+### Accessibility snapshot
+
+```ts
+accessibilitySnapshot(root?: Element | Document): AccessibilityNode
+
+interface AccessibilityNode {
+ role: string // WAI-ARIA role; "text" for text leaves
+ name: string // accessible name, "" when none
+ description: string // accessible description, "" when none
+ value?: string // textbox, searchbox, combobox and range roles
+ states: Record
+ element?: Element // absent on text leaves
+ children: AccessibilityNode[]
+}
+```
+
+```js
+import accessibilitySnapshot from '@selenium/atoms/accessibility-snapshot'
+
+const tree = accessibilitySnapshot() // the whole document
+const dialog = accessibilitySnapshot(dialogElement) // one subtree
+```
+
+The snapshot is *computed* by script from the DOM, following WAI-ARIA 1.2,
+html-aam and accname 1.2. It is not the browser's own accessibility tree, and
+it can differ from it at the edges. Elements that are hidden in the accname
+sense are left out; generic wrappers are pruned and their children hoisted;
+text an ancestor's name was computed from is not repeated as leaves. Frames are
+reported as `iframe` leaves with no children, whatever role attribute they
+carry, since a child document is another browsing context the page cannot
+reach; callers that can reach it stitch its snapshot in. Keys in `states` are
+`checked`, `pressed`, `expanded`, `selected`, `level`, `disabled` and
+`focused`; new keys may be added in any release, so ignore keys you do not
+recognise. A password field's `value` is masked, one bullet per character, as
+the browser's own tree exposes it.
+
+`accessibilitySnapshotWire(root?: Element)` returns the same snapshot in the
+shape the Selenium bindings fetch over WebDriver BiDi: `{ json, elements,
+windows }`, the tree as JSON text with each `element` replaced by an index into
+`elements` and each frame node given a `frame` index into `windows` (the
+frame's content window), so a binding parses one string and maps two arrays
+instead of decoding a nested remote value. It answers `null` for a root this
+document does not own (one owned by another document, or a `null` root, which
+is how Firefox hands over a shared id of another document's node) and
+`{ error: 'detached' }` for a root no longer in its document. Page scripts have
+no reason to use it over `accessibilitySnapshot`.
+
The atoms operate on the DOM of the document they are evaluated in, so they must
run in the page rather than in the driver process — for example via
`Runtime.evaluate` over CDP, `script.callFunction` over WebDriver BiDi, or
diff --git a/javascript/atoms/fragments/BUILD.bazel b/javascript/atoms/fragments/BUILD.bazel
index 611a74f5ca325..641b4f8c65ad2 100644
--- a/javascript/atoms/fragments/BUILD.bazel
+++ b/javascript/atoms/fragments/BUILD.bazel
@@ -263,3 +263,29 @@ copy_file(
"//rb/lib/selenium/webdriver/atoms:__pkg__",
],
)
+
+copy_file(
+ name = "accessibility-snapshot-typescript",
+ src = "//javascript/atoms:typescript/accessibility-snapshot.generated.js",
+ out = "accessibility-snapshot-typescript.js",
+ visibility = [
+ "//dotnet/src/webdriver:__pkg__",
+ "//java/src/org/openqa/selenium/remote:__pkg__",
+ "//javascript/selenium-webdriver/lib/atoms:__pkg__",
+ "//py:__pkg__",
+ "//rb/lib/selenium/webdriver/atoms:__pkg__",
+ ],
+)
+
+copy_file(
+ name = "accessibility-snapshot-wire-typescript",
+ src = "//javascript/atoms:typescript/accessibility-snapshot-wire.generated.js",
+ out = "accessibility-snapshot-wire-typescript.js",
+ visibility = [
+ "//dotnet/src/webdriver:__pkg__",
+ "//java/src/org/openqa/selenium/remote:__pkg__",
+ "//javascript/selenium-webdriver/lib/atoms:__pkg__",
+ "//py:__pkg__",
+ "//rb/lib/selenium/webdriver/atoms:__pkg__",
+ ],
+)
diff --git a/javascript/atoms/index.ts b/javascript/atoms/index.ts
index 1481cd06a5169..d5fac6a10fba0 100644
--- a/javascript/atoms/index.ts
+++ b/javascript/atoms/index.ts
@@ -61,3 +61,7 @@ export { default as click } from './typescript/click.js'
export { default as type } from './typescript/type.js'
export { default as clear } from './typescript/clear.js'
export { default as submit } from './typescript/submit.js'
+export { default as accessibilitySnapshot } from './typescript/accessibility-snapshot.js'
+export type { AccessibilityNode, StateValue } from './typescript/accessibility-snapshot.js'
+export { default as accessibilitySnapshotWire } from './typescript/accessibility-snapshot-wire.js'
+export type { WireError, WireNode, WireSnapshot } from './typescript/accessibility-snapshot-wire.js'
diff --git a/javascript/atoms/package.json b/javascript/atoms/package.json
index 30d834a9ae4ce..93c7017f86059 100644
--- a/javascript/atoms/package.json
+++ b/javascript/atoms/package.json
@@ -181,6 +181,14 @@
"./submit": {
"types": "./dist/typescript/submit.d.ts",
"default": "./dist/typescript/submit.js"
+ },
+ "./accessibility-snapshot": {
+ "types": "./dist/typescript/accessibility-snapshot.d.ts",
+ "default": "./dist/typescript/accessibility-snapshot.js"
+ },
+ "./accessibility-snapshot-wire": {
+ "types": "./dist/typescript/accessibility-snapshot-wire.d.ts",
+ "default": "./dist/typescript/accessibility-snapshot-wire.js"
}
},
"typesVersions": {
@@ -227,7 +235,9 @@
"click": ["./dist/typescript/click.d.ts"],
"type": ["./dist/typescript/type.d.ts"],
"clear": ["./dist/typescript/clear.d.ts"],
- "submit": ["./dist/typescript/submit.d.ts"]
+ "submit": ["./dist/typescript/submit.d.ts"],
+ "accessibility-snapshot": ["./dist/typescript/accessibility-snapshot.d.ts"],
+ "accessibility-snapshot-wire": ["./dist/typescript/accessibility-snapshot-wire.d.ts"]
}
},
"engines": {
diff --git a/javascript/atoms/test/accessibility_bench.html b/javascript/atoms/test/accessibility_bench.html
new file mode 100644
index 0000000000000..240701350d518
--- /dev/null
+++ b/javascript/atoms/test/accessibility_bench.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+ accessibility_bench.html
+
+
+
+
+
+ accessibility-snapshot benchmark
+
+ Not a test (the suite only runs *_test.html). Open it from the debug server to
+ measure the atom on fixed tree shapes: time per element and how many DOM calls the walk
+ makes. The numbers are for comparing one build of the atom with another on the same machine,
+ not for CI. Results are also in data-results on the body, as JSON.
+
+
+ shape elements nodes ms µs / element JSON KB
+ getComputedStyle getAttribute closest querySelectorAll getElementById innerText
+
+
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_corpus_typescript_test.html b/javascript/atoms/test/accessibility_corpus_typescript_test.html
new file mode 100644
index 0000000000000..fcaa4c389b314
--- /dev/null
+++ b/javascript/atoms/test/accessibility_corpus_typescript_test.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+ accessibility_corpus_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_description_typescript_test.html b/javascript/atoms/test/accessibility_description_typescript_test.html
new file mode 100644
index 0000000000000..7de02beb39702
--- /dev/null
+++ b/javascript/atoms/test/accessibility_description_typescript_test.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ accessibility_description_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+ Help text More help
+ Hidden help
+ A paragraph of help.
+ Save
+ Save
+ Save
+ Save
+ Save
+ Save
+ Save
+
+
+ Save
+
+
+
diff --git a/javascript/atoms/test/accessibility_hidden_typescript_test.html b/javascript/atoms/test/accessibility_hidden_typescript_test.html
new file mode 100644
index 0000000000000..2a5af85c9cc78
--- /dev/null
+++ b/javascript/atoms/test/accessibility_hidden_typescript_test.html
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+ accessibility_hidden_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ x
+ x
+ x
+
+ x
+ x
+ x
+ x
+
+ x
+ x
+
+ x
+
+
+ summary
+ x
+
+
+ summary
+ x
+
+
+
+
+ x
+
+ x
+
+ x
+ x
+ x
+
+
+ x
+
+
+
+
+
+
+
+
+
+ x
+ x
+ x
+ x
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_name_typescript_test.html b/javascript/atoms/test/accessibility_name_typescript_test.html
new file mode 100644
index 0000000000000..c4064a5bd3dab
--- /dev/null
+++ b/javascript/atoms/test/accessibility_name_typescript_test.html
@@ -0,0 +1,395 @@
+
+
+
+
+
+
+ accessibility_name_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+ First Second
+ x
+ x
+ x
+ Hidden label
+ x
+ Aria hidden label
+ x
+ Units
+
+ x
+ A content
+ B content
+ C content
+ Content
+ Qty
+ x
+
+ X
+ X
+ x
+ Content
+
+ Email
+ First Second
+ Choice a
+ Notes
+ Act Content
+ Name
+ Agree
+ Quantity units
+
+
+
+
+ Send the form
+ Start again
+
+
+
+
+
+ Legend text
+ Caption text
+
+
+
+ One
+ Two
+ Three
+ Four
+
+ Chart
+
+
+
+
+
+
+
+ Title
+ Go now
+ Save
+
+ Tab
+ Remember me
+ Para text
+ Generic text
+ Dialog text
+
+ Nav text
+ Savenow
+ A
B
+ A B
+ Line one Line two
+ Save x y
+ lots of
+ space
+ Save
+
+ x
+ Para text
+ Count items
+ Pick Alpha Beta please
+ Save
+ Save
+ Save
+ Save
+ Save
+ Light
+ Save
+ hi
+ Save
+ Save
+ Save
+ Save
+
+
+ Save
+ Count Inner label
+ A B
+
+
+ Save x vis
+ Save
+
+
+ WWW
+ Save
+
+
+
diff --git a/javascript/atoms/test/accessibility_owns_typescript_test.html b/javascript/atoms/test/accessibility_owns_typescript_test.html
new file mode 100644
index 0000000000000..21be4e4ba13db
--- /dev/null
+++ b/javascript/atoms/test/accessibility_owns_typescript_test.html
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+ accessibility_owns_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Before
+
Child
+
After
+
Owned
+
+
+
+
+
+ outside
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_robustness_typescript_test.html b/javascript/atoms/test/accessibility_robustness_typescript_test.html
new file mode 100644
index 0000000000000..d452774c64b87
--- /dev/null
+++ b/javascript/atoms/test/accessibility_robustness_typescript_test.html
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+ accessibility_robustness_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content
+ x
+ x
+ First of two Second of two
+ x
+ 🚀 Launch Zoë
+ שלום
+ x
+ x
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_roles_typescript_test.html b/javascript/atoms/test/accessibility_roles_typescript_test.html
new file mode 100644
index 0000000000000..71d4b45e63119
--- /dev/null
+++ b/javascript/atoms/test/accessibility_roles_typescript_test.html
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+ accessibility_roles_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+ nav
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ labelled by an empty element
+
+ search
+
+
+
+
+
+
+
+ button
+
+ x
+ x
+ x
+ x
+ x
+
+
+ x
+
+
+
+ x
+ x
+ x
+ x
+ x
+
+
+
+ h1 h2 h3 h4 h5 h6
+ p
+ quote
+ code em strong
+ sub sup del ins
+ today
+
+ caption
+ term
+ x
+ div
span b i abbr
+ custom
+ summary body
+ dialog
+
+ link anchor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ one
+
+
orphan
+ term definition
+
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_shadow_typescript_test.html b/javascript/atoms/test/accessibility_shadow_typescript_test.html
new file mode 100644
index 0000000000000..1a595a864d170
--- /dev/null
+++ b/javascript/atoms/test/accessibility_shadow_typescript_test.html
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+ accessibility_shadow_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ loose light text
+
+
+
+
+
+
+ Pic
+ Light
+ Light
+ Light
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_states_typescript_test.html b/javascript/atoms/test/accessibility_states_typescript_test.html
new file mode 100644
index 0000000000000..da12b7a5e74a5
--- /dev/null
+++ b/javascript/atoms/test/accessibility_states_typescript_test.html
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+ accessibility_states_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ a
+ b
+ c
+ d
+ e
+ f
+ g
+ h
+
+
+
+
+ p
+ p
+ p
+ p
+ e
+ e
+ open x
+ closed x
+
+ u s
+
+
+
+
+
+ 1 2 3 4 5 6
+ 4
+ 3
+ 2
+ 3
+ 2
+
+
+ d
+
+ d
+ d
+ d
+
+
h
+
+
+
+ p
+ e
+
+ x
+
+
+ Heading text
+ para text
+
+
+
diff --git a/javascript/atoms/test/accessibility_tree_typescript_test.html b/javascript/atoms/test/accessibility_tree_typescript_test.html
new file mode 100644
index 0000000000000..09c0e3ae9cc3a
--- /dev/null
+++ b/javascript/atoms/test/accessibility_tree_typescript_test.html
@@ -0,0 +1,329 @@
+
+
+
+
+
+
+ Accessibility snapshot fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Title
+
plain text
+
Save now
+
+
never
+
never
+
+ nav text
+ Seen
+ Unseen
+
+
More hidden para
+
+
+
+
Go now
+
+
+
lots of
+ space
+
Remember me
+
+
+
+
+
+
+
+ S loose textpara
+ Hidden inert text
Shown
+ never text
+ default text
+ no video support
no audio support
+
+
+
+
+
+
+
diff --git a/javascript/atoms/test/accessibility_value_typescript_test.html b/javascript/atoms/test/accessibility_value_typescript_test.html
new file mode 100644
index 0000000000000..6494028a5c6d5
--- /dev/null
+++ b/javascript/atoms/test/accessibility_value_typescript_test.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ accessibility_value_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ default text
+
+
+ hello secret
+ Alpha Beta
+ Text
+
+
+
+
+
+
+
+
+
+
+ b
+
+
+ h
+ l
+
+
+
diff --git a/javascript/atoms/test/accessibility_wire_typescript_test.html b/javascript/atoms/test/accessibility_wire_typescript_test.html
new file mode 100644
index 0000000000000..aa15829e3a060
--- /dev/null
+++ b/javascript/atoms/test/accessibility_wire_typescript_test.html
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+ accessibility_wire_typescript_test.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome
+
Save
+
Email
+
+
+
+
+
+
+
diff --git a/javascript/atoms/test/probe.html b/javascript/atoms/test/probe.html
new file mode 100644
index 0000000000000..159a43d856b5e
--- /dev/null
+++ b/javascript/atoms/test/probe.html
@@ -0,0 +1,18 @@
+
+1 2 3 4 5
+
+
diff --git a/javascript/atoms/tsconfig.json b/javascript/atoms/tsconfig.json
index 6f8dfde598f5f..18010732c642a 100644
--- a/javascript/atoms/tsconfig.json
+++ b/javascript/atoms/tsconfig.json
@@ -9,5 +9,5 @@
"outDir": "dist",
"strict": false
},
- "include": ["index.ts", "typescript/*.ts", "typescript/core/*.ts"]
+ "include": ["index.ts", "typescript/*.ts", "typescript/core/**/*.ts"]
}
diff --git a/javascript/atoms/typescript/accessibility-node.schema.json b/javascript/atoms/typescript/accessibility-node.schema.json
new file mode 100644
index 0000000000000..daf82cdd81f79
--- /dev/null
+++ b/javascript/atoms/typescript/accessibility-node.schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://www.selenium.dev/schemas/accessibility-node.json",
+ "title": "AccessibilityNode",
+ "description": "One node of the accessibility snapshot of ADR 18205 (docs/decisions/18205-accessibility-tree-snapshot.md), as the shared corpus records it: `element` is the element's id. Adding an optional field or a states key is additive; removing or renaming one, or changing a type, is a breaking change that needs a new decision record.",
+ "$ref": "#/$defs/node",
+ "$defs": {
+ "node": {
+ "type": "object",
+ "required": ["role", "name", "description", "states", "children"],
+ "additionalProperties": false,
+ "properties": {
+ "role": { "type": "string", "minLength": 1, "description": "WAI-ARIA role token; \"text\" for text leaves, \"iframe\" for frame leaves." },
+ "name": { "type": "string", "description": "Computed accessible name; \"\" when there is none." },
+ "description": { "type": "string", "description": "Computed accessible description; \"\" when there is none." },
+ "value": { "type": "string", "description": "Current value; present only for textbox, searchbox, combobox and range roles." },
+ "states": { "$ref": "#/$defs/states" },
+ "element": { "type": "string", "minLength": 1, "description": "The id of the backing element; absent for text leaves." },
+ "children": { "type": "array", "items": { "$ref": "#/$defs/node" } }
+ }
+ },
+ "states": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "checked": { "oneOf": [{ "type": "boolean" }, { "const": "mixed" }] },
+ "pressed": { "oneOf": [{ "type": "boolean" }, { "const": "mixed" }] },
+ "expanded": { "type": "boolean" },
+ "selected": { "type": "boolean" },
+ "level": { "type": "integer", "minimum": 1 },
+ "disabled": { "const": true },
+ "focused": { "const": true }
+ }
+ }
+ }
+}
diff --git a/javascript/atoms/typescript/accessibility-snapshot-wire.ts b/javascript/atoms/typescript/accessibility-snapshot-wire.ts
new file mode 100644
index 0000000000000..53234d60b8274
--- /dev/null
+++ b/javascript/atoms/typescript/accessibility-snapshot-wire.ts
@@ -0,0 +1,19 @@
+// 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.
+
+export type { WireError, WireNode, WireSnapshot } from './core/accessibility/wire.js'
+export { default } from './core/accessibility/wire.js'
diff --git a/javascript/atoms/typescript/accessibility-snapshot.ts b/javascript/atoms/typescript/accessibility-snapshot.ts
new file mode 100644
index 0000000000000..e8f99403ce9e5
--- /dev/null
+++ b/javascript/atoms/typescript/accessibility-snapshot.ts
@@ -0,0 +1,21 @@
+// 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.
+
+// The implementation lives in core/accessibility/ because it spans several
+// modules; an atom entry point only re-exports.
+export type { AccessibilityNode, StateValue } from './core/accessibility/types.js'
+export { default } from './core/accessibility/snapshot.js'
diff --git a/javascript/atoms/typescript/core/accessibility/accname.ts b/javascript/atoms/typescript/core/accessibility/accname.ts
new file mode 100644
index 0000000000000..dd061c9e79320
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/accname.ts
@@ -0,0 +1,587 @@
+// 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.
+
+import { NameResult, roleOf, SnapshotContext, styleOf } from './context.js'
+import { flatTreeChildren, hiddenState, isClosedDetails, isCollapsedDetailsContent, isHidden } from './hidden.js'
+import { embeddedValue, isEmbeddedControl } from './value.js'
+
+// Accessible name and description, following the accname 1.2 text
+// alternative computation (https://w3c.github.io/accname/#computation-steps)
+// with the html-aam rules for native elements. Step numbers in comments refer
+// to that algorithm.
+
+const TEXT_NODE = 3
+const ELEMENT_NODE = 1
+
+// WAI-ARIA 1.2 roles with "Name From: contents".
+const NAME_FROM_CONTENT: ReadonlySet = new Set([
+ 'button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem',
+ 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab',
+ 'tooltip', 'treeitem',
+])
+
+// The input types that take a placeholder.
+const TEXT_INPUT_TYPES: ReadonlySet = new Set(['email', 'number', 'password', 'search', 'tel', 'text', 'url'])
+
+// Display values whose boxes sit inline with text; everything else is treated
+// as block-level when children are joined (2F.iii).
+const INLINE_DISPLAYS: ReadonlySet = new Set([
+ 'inline', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table', 'contents', 'ruby',
+ 'ruby-base', 'ruby-text', '',
+])
+
+interface Traversal {
+ readonly context: SnapshotContext
+ /** True while computing a description rather than a name. */
+ readonly describing: boolean
+ /** True inside an aria-labelledby / aria-describedby reference (2B is not applied again). */
+ readonly inReference: boolean
+ /** True for descendants of the element being named (2F.iii recursion) and for native label traversals. */
+ readonly recursive: boolean
+ /** True when the traversal root was hidden, so hidden descendants count too (2A). */
+ readonly includeHidden: boolean
+ /** The labelled control while its own label is traversed; it does not name itself. */
+ readonly skip: Element | null
+}
+
+function normalize(text: string): string {
+ return text.replace(/\s+/g, ' ').trim()
+}
+
+function attribute(element: Element, name: string): string | null {
+ const value = element.getAttribute(name)
+ return value !== null && value.trim() !== '' ? value : null
+}
+
+function rootNodeOf(element: Element): Document | ShadowRoot {
+ return element.getRootNode() as Document | ShadowRoot
+}
+
+function referencedElements(element: Element, attributeName: string): Element[] {
+ const ids = element.getAttribute(attributeName)
+ if (!ids) {
+ return []
+ }
+ const root = rootNodeOf(element)
+ const found: Element[] = []
+ for (const id of ids.trim().split(/\s+/)) {
+ const target = root.getElementById(id)
+ if (target) {
+ found.push(target)
+ }
+ }
+ return found
+}
+
+// 2B: the text alternatives of the referenced elements, space-joined, an id
+// listed twice counting once (as Chromium reads it). A target's text
+// alternative is the same whoever references it and whether for a name or a
+// description (2B is not applied inside a reference and 2C is), so it is
+// computed once per snapshot: a header that labels every cell of a column is
+// read once, not once per cell. No cycle check is needed beyond
+// `inReference`: nothing below a reference follows references again.
+function fromReferences(element: Element, attributeName: string, t: Traversal): string {
+ const parts: string[] = []
+ const listed = new Set()
+ for (const target of referencedElements(element, attributeName)) {
+ if (listed.has(target)) {
+ continue
+ }
+ listed.add(target)
+ let text = t.context.referenceTexts.get(target)
+ if (text === undefined) {
+ text = textAlternative(target, {
+ ...t,
+ inReference: true,
+ recursive: false,
+ includeHidden: isHidden(target, t.context),
+ skip: null,
+ })
+ t.context.referenceTexts.set(target, text)
+ }
+ parts.push(text)
+ }
+ return normalize(parts.join(' '))
+}
+
+const GENERATED_CONTENT_SELECTOR = /::?(before|after)\b/i
+
+// Whether one stylesheet has a rule that can set ::before or ::after content,
+// looking into grouping rules (@media, @supports, @layer, @container, nested
+// rules) and imports. A sheet whose rules cannot be read (cross-origin)
+// counts as defining them: a wrong "no" would drop real text, a wrong "yes"
+// only costs the reads.
+function sheetDefinesGeneratedContent(sheet: CSSStyleSheet): boolean {
+ let rules: CSSRuleList
+ try {
+ rules = sheet.cssRules
+ } catch (e) {
+ return true
+ }
+ return rulesDefineGeneratedContent(rules)
+}
+
+function rulesDefineGeneratedContent(rules: CSSRuleList): boolean {
+ for (let i = 0; i < rules.length; i++) {
+ const rule = rules[i] as CSSRule & { selectorText?: string; cssRules?: CSSRuleList; styleSheet?: CSSStyleSheet | null }
+ if (typeof rule.selectorText === 'string' && GENERATED_CONTENT_SELECTOR.test(rule.selectorText)) {
+ return true
+ }
+ if (rule.styleSheet && sheetDefinesGeneratedContent(rule.styleSheet)) {
+ return true
+ }
+ if (rule.cssRules && rulesDefineGeneratedContent(rule.cssRules)) {
+ return true
+ }
+ }
+ return false
+}
+
+// Whether the sheets of one tree scope (the document or a shadow root) can
+// define generated content; cached per scope for the call.
+function scopeDefinesGeneratedContent(scope: Document | ShadowRoot, t: Traversal): boolean {
+ let defines = t.context.generatedScopes.get(scope)
+ if (defines === undefined) {
+ defines = false
+ const sheets = scope.styleSheets
+ for (let i = 0; i < sheets.length && !defines; i++) {
+ defines = sheetDefinesGeneratedContent(sheets[i])
+ }
+ const adopted = (scope as { adoptedStyleSheets?: CSSStyleSheet[] }).adoptedStyleSheets
+ if (adopted) {
+ for (let i = 0; i < adopted.length && !defines; i++) {
+ defines = sheetDefinesGeneratedContent(adopted[i])
+ }
+ }
+ t.context.generatedScopes.set(scope, defines)
+ }
+ return defines
+}
+
+// The scopes whose sheets can style an element's pseudo-elements: its own
+// tree, its shadow root (through :host::before), the tree of the slot it is
+// assigned to (through ::slotted()::before), and, for an element with a part
+// name, every enclosing tree (through ::part()::before). The UA stylesheet is
+// not consulted: its only generated content is the open-quote and
+// close-quote keywords on q, which are not strings and are not text the atom
+// can read (the names page pins that).
+function mayHaveGeneratedContent(element: Element, t: Traversal): boolean {
+ if (scopeDefinesGeneratedContent(rootNodeOf(element), t)) {
+ return true
+ }
+ if (element.shadowRoot && scopeDefinesGeneratedContent(element.shadowRoot, t)) {
+ return true
+ }
+ const slot = element.assignedSlot
+ if (slot && scopeDefinesGeneratedContent(rootNodeOf(slot), t)) {
+ return true
+ }
+ if (element.hasAttribute('part')) {
+ for (let host = (rootNodeOf(element) as ShadowRoot).host; host; host = (rootNodeOf(host) as ShadowRoot).host) {
+ if (scopeDefinesGeneratedContent(rootNodeOf(host), t)) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// Generated content of ::before and ::after, read once per element and only
+// when some sheet in scope can define it: pseudo-element style reads are the
+// most expensive kind, and every element in a name-from-content traversal
+// would otherwise pay two.
+function pseudoContent(element: Element, pseudo: '::before' | '::after', t: Traversal): string {
+ let texts = t.context.generated.get(element)
+ if (texts === undefined) {
+ texts = mayHaveGeneratedContent(element, t)
+ ? [readPseudoContent(element, '::before', t), readPseudoContent(element, '::after', t)]
+ : ['', '']
+ t.context.generated.set(element, texts)
+ }
+ return pseudo === '::before' ? texts[0] : texts[1]
+}
+
+// The index just past the quoted string that opens at `start`.
+function endOfString(content: string, start: number): number {
+ const quote = content[start]
+ let i = start + 1
+ while (i < content.length && content[i] !== quote) {
+ if (content[i] === '\\' && i + 1 < content.length) {
+ i++
+ }
+ i++
+ }
+ return i + 1
+}
+
+// The index just past the function whose opening parenthesis is at `start`,
+// so url("...") and the like, whose quoted arguments are not text, are read
+// as one token.
+function endOfFunction(content: string, start: number): number {
+ let depth = 0
+ let i = start
+ while (i < content.length) {
+ const char = content[i]
+ if (char === '"' || char === "'") {
+ i = endOfString(content, i)
+ continue
+ }
+ if (char === '(') {
+ depth++
+ } else if (char === ')' && --depth === 0) {
+ return i + 1
+ }
+ i++
+ }
+ return i
+}
+
+// An attr() token in computed `content`: Chromium resolves it to a string
+// before serialising, Firefox keeps the function, so the attribute is read
+// from the originating element here. Only the attribute name is read; a
+// fallback or type argument is ignored, as the attribute's string value is
+// what both engines render.
+function readAttrToken(element: Element, token: string): string {
+ const match = /^attr\(\s*([^\s,)]+)/.exec(token)
+ return match ? element.getAttribute(match[1]) || '' : ''
+}
+
+// The computed `content` is a list of strings and other tokens; only the
+// strings and attr() references are text, and a bare slash introduces
+// alternative text that replaces everything before it. A slash inside a
+// quoted string is just a character, and a quoted string inside a function
+// (the URL of an image) is not text. A pseudo-element that is not rendered
+// has no text at all.
+function readPseudoContent(element: Element, pseudo: string, t: Traversal): string {
+ const style = styleOf(element, t.context, pseudo)
+ if (!style || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') {
+ return ''
+ }
+ const content = style.content
+ if (!content || content === 'none' || content === 'normal') {
+ return ''
+ }
+ let text = ''
+ let i = 0
+ while (i < content.length) {
+ const char = content[i]
+ if (char === '"' || char === "'") {
+ const end = endOfString(content, i)
+ text += content.slice(i + 1, end - 1).replace(/\\(.)/g, '$1')
+ i = end
+ } else if (char === '(') {
+ const end = endOfFunction(content, i)
+ const start = content.lastIndexOf(' ', i) + 1
+ text += readAttrToken(element, content.slice(start, end))
+ i = end
+ } else if (char === '/') {
+ text = ''
+ i++
+ } else {
+ i++
+ }
+ }
+ return text
+}
+
+function isInline(element: Element, t: Traversal): boolean {
+ const style = styleOf(element, t.context)
+ return !style || INLINE_DISPLAYS.has(style.display)
+}
+
+// 2F: the concatenated text alternatives of the children, with generated
+// content around them and block-level children set off by spaces.
+//
+// Cached per element for the traversal every enclosing content-named
+// ancestor repeats (a cell's text is wanted by the row, then by the cell, then
+// by a link inside it): no reference being followed, no hidden content
+// included, no control skipped, a name rather than a description. The
+// `recursive` flag does not change the result, since children are always
+// traversed as recursive. Other traversals compute afresh.
+function fromContent(element: Element, t: Traversal): string {
+ const cacheable = !t.inReference && !t.includeHidden && t.skip === null && !t.describing
+ if (!cacheable) {
+ return computeContentText(element, t)
+ }
+ let text = t.context.contentTexts.get(element)
+ if (text === undefined) {
+ text = computeContentText(element, t)
+ t.context.contentTexts.set(element, text)
+ }
+ return text
+}
+
+function computeContentText(element: Element, t: Traversal): string {
+ // An invisible element's own text and generated content are not rendered;
+ // visibility is inherited, so only descendants that set themselves visible
+ // again contribute, through the recursion below.
+ const ownTextHidden = !t.includeHidden && hiddenState(element, t.context) === 'self'
+ let text = ownTextHidden ? '' : pseudoContent(element, '::before', t)
+ const textCollapsed = isClosedDetails(element)
+ const children = flatTreeChildren(element)
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ if (child.nodeType === TEXT_NODE) {
+ if (!ownTextHidden && !textCollapsed) {
+ text += child.nodeValue || ''
+ }
+ continue
+ }
+ if (child.nodeType !== ELEMENT_NODE) {
+ continue
+ }
+ const childElement = child as Element
+ if (childElement === t.skip || isCollapsedDetailsContent(childElement, childElement.parentElement || element)) {
+ continue
+ }
+ if (childElement.localName === 'br') {
+ text += ' '
+ continue
+ }
+ const childState = t.includeHidden ? 'rendered' : hiddenState(childElement, t.context)
+ if (childState === 'subtree' || childState === 'contents') {
+ continue
+ }
+ // An invisible child has no text alternative of its own (2A); only what
+ // is visible below it counts.
+ const childText =
+ childState === 'self'
+ ? fromContent(childElement, { ...t, recursive: true })
+ : textAlternative(childElement, { ...t, recursive: true })
+ text += isInline(childElement, t) ? childText : ` ${childText} `
+ }
+ return text + (ownTextHidden ? '' : pseudoContent(element, '::after', t))
+}
+
+function firstChildNamed(element: Element, localName: string): Element | null {
+ for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
+ if (child.localName === localName) {
+ return child
+ }
+ }
+ return null
+}
+
+// html-aam: the labels of a labelable element, each traversed with the
+// control itself left out so a wrapping label does not include its value.
+function fromLabels(element: Element, t: Traversal): string | null {
+ const labels = (element as HTMLInputElement).labels
+ if (!labels || labels.length === 0) {
+ return null
+ }
+ const parts: string[] = []
+ for (let i = 0; i < labels.length; i++) {
+ const label = labels[i]
+ parts.push(fromContent(label, { ...t, recursive: true, includeHidden: isHidden(label, t.context), skip: element }))
+ }
+ const text = normalize(parts.join(' '))
+ return text !== '' ? text : null
+}
+
+function inputNativeName(input: HTMLInputElement, t: Traversal): string | null {
+ switch (input.type) {
+ case 'button':
+ case 'reset':
+ case 'submit': {
+ // html-aam: label elements, then the value, then the user agent's
+ // default button labels.
+ const label = fromLabels(input, t)
+ if (label !== null) {
+ return label
+ }
+ const value = attribute(input, 'value')
+ if (value !== null) {
+ return value
+ }
+ return input.type === 'submit' ? 'Submit' : input.type === 'reset' ? 'Reset' : null
+ }
+ case 'image':
+ return attribute(input, 'alt') || fromLabels(input, t) || attribute(input, 'value')
+ default:
+ return fromLabels(input, t)
+ }
+}
+
+// 2D: text alternatives the host language defines.
+function nativeName(element: Element, t: Traversal): string | null {
+ switch (element.localName) {
+ case 'html':
+ return (element.ownerDocument.title || '').trim() || null
+ case 'input':
+ return inputNativeName(element as HTMLInputElement, t)
+ case 'button':
+ case 'meter':
+ case 'output':
+ case 'progress':
+ case 'select':
+ case 'textarea':
+ return fromLabels(element, t)
+ case 'fieldset': {
+ const legend = firstChildNamed(element, 'legend')
+ return legend ? normalize(fromContent(legend, { ...t, recursive: true })) || null : null
+ }
+ case 'figure': {
+ const caption = firstChildNamed(element, 'figcaption')
+ return caption ? normalize(fromContent(caption, { ...t, recursive: true })) || null : null
+ }
+ case 'table': {
+ const caption = firstChildNamed(element, 'caption')
+ return caption ? normalize(fromContent(caption, { ...t, recursive: true })) || null : null
+ }
+ case 'img':
+ case 'area':
+ return attribute(element, 'alt')
+ case 'optgroup':
+ return attribute(element, 'label')
+ case 'option':
+ return attribute(element, 'label')
+ case 'svg': {
+ const title = firstChildNamed(element, 'title')
+ return title ? normalize(title.textContent || '') || null : null
+ }
+ default:
+ return null
+ }
+}
+
+function placeholder(element: Element): string | null {
+ if (element.localName === 'textarea') {
+ return attribute(element, 'placeholder')
+ }
+ if (element.localName === 'input' && TEXT_INPUT_TYPES.has((element as HTMLInputElement).type)) {
+ return attribute(element, 'placeholder')
+ }
+ return null
+}
+
+// The traversal every content-named ancestor repeats for the same descendant:
+// a cell is a descendant of its row, a link of its cell. Its result depends
+// on nothing but the element, so it is cached per element; the top-level
+// result (computeName) differs in steps 2C, 2E and 2F and has its own cache.
+function isStandardDescendantTraversal(t: Traversal): boolean {
+ return t.recursive && !t.inReference && !t.includeHidden && t.skip === null && !t.describing
+}
+
+function textAlternative(node: Node, t: Traversal): string {
+ if (node.nodeType === TEXT_NODE) {
+ return node.nodeValue || ''
+ }
+ if (node.nodeType !== ELEMENT_NODE) {
+ return ''
+ }
+ const element = node as Element
+ if (!isStandardDescendantTraversal(t)) {
+ return computeTextAlternative(element, t).text
+ }
+ let text = t.context.descendantTexts.get(element)
+ if (text === undefined) {
+ text = computeTextAlternative(element, t).text
+ t.context.descendantTexts.set(element, text)
+ }
+ return text
+}
+
+// The computation for one element, also used for the element being named.
+function computeTextAlternative(element: Element, t: Traversal): NameResult {
+ const role = roleOf(element, t.context)
+ const embedded = (t.recursive || t.inReference) && isEmbeddedControl(role)
+
+ // 2B
+ if (!t.inReference) {
+ const referenced = fromReferences(element, t.describing ? 'aria-describedby' : 'aria-labelledby', t)
+ if (referenced !== '') {
+ return { text: referenced, source: 'reference' }
+ }
+ }
+ // 2C. The element being described does not take aria-label; a referenced
+ // element does, because its text alternative is what is being read.
+ if (!t.describing || t.inReference) {
+ const label = attribute(element, 'aria-label')
+ if (label !== null && !embedded) {
+ return { text: normalize(label), source: 'label' }
+ }
+ }
+ // 2E, ahead of 2D: a control reached while naming something else
+ // contributes its value, not its own label. That is what the user sees in
+ // the enclosing label, and it is also what stops two labels that wrap each
+ // other's control from recursing without end.
+ if (embedded) {
+ const value = embeddedValue(element, role)
+ if (value !== null) {
+ return { text: normalize(value), source: 'native' }
+ }
+ }
+ // 2D. A present alt attribute is the final answer for an image, even when
+ // empty (html-aam): a decorative image does not pick up its title.
+ if ((element.localName === 'img' || element.localName === 'area') && element.hasAttribute('alt')) {
+ return { text: normalize(element.getAttribute('alt') || ''), source: 'native' }
+ }
+ const native = nativeName(element, t)
+ if (native !== null && normalize(native) !== '') {
+ return { text: normalize(native), source: 'native' }
+ }
+ // 2F, 2H
+ if (NAME_FROM_CONTENT.has(role) || t.recursive || t.inReference) {
+ const content = normalize(fromContent(element, t))
+ if (content !== '') {
+ return { text: content, source: 'content' }
+ }
+ }
+ // 2I
+ const title = attribute(element, 'title')
+ if (title !== null) {
+ return { text: normalize(title), source: 'title' }
+ }
+ const hint = placeholder(element)
+ if (hint !== null) {
+ return { text: normalize(hint), source: 'placeholder' }
+ }
+ return { text: '', source: 'none' }
+}
+
+function traversal(context: SnapshotContext, describing: boolean): Traversal {
+ return { context, describing, inReference: false, recursive: false, includeHidden: false, skip: null }
+}
+
+/** The accessible name of an element and where it came from. */
+export function computeName(element: Element, context: SnapshotContext): NameResult {
+ let result = context.names.get(element)
+ if (result === undefined) {
+ result = computeTextAlternative(element, traversal(context, false))
+ context.names.set(element, result)
+ }
+ return result
+}
+
+/** The accessible description: aria-describedby, aria-description, then title when the name did not use it. */
+export function computeDescription(element: Element, context: SnapshotContext): string {
+ let description = context.descriptions.get(element)
+ if (description === undefined) {
+ description = fromReferences(element, 'aria-describedby', traversal(context, true))
+ if (description === '') {
+ const inline = attribute(element, 'aria-description')
+ if (inline !== null) {
+ description = normalize(inline)
+ } else {
+ const title = attribute(element, 'title')
+ description = title !== null && computeName(element, context).source !== 'title' ? normalize(title) : ''
+ }
+ }
+ context.descriptions.set(element, description)
+ }
+ return description
+}
diff --git a/javascript/atoms/typescript/core/accessibility/context.ts b/javascript/atoms/typescript/core/accessibility/context.ts
new file mode 100644
index 0000000000000..ebf3413499fd3
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/context.ts
@@ -0,0 +1,113 @@
+// 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.
+
+import type { HiddenState } from './hidden.js'
+import { computeRole } from './roles.js'
+
+// State shared by one snapshot call. It lives exactly as long as the call:
+// the snapshot is a copy taken at one point in time (ADR 18205 §1), and a
+// cache that outlived it would hand back stale roles or names after the page
+// changed an attribute, because element identity does not change with it.
+// Nothing in the DOM can change during the synchronous walk, so memoising
+// per-element results within the call is safe.
+//
+// Roles, hidden states and styles are cached because name computation asks
+// for the roles, hidden-ness and display of descendants and of
+// aria-labelledby targets that the walk also visits; names are cached
+// because several nodes can reference the same element.
+export interface SnapshotContext {
+ readonly roles: Map
+ readonly hidden: Map
+ /** Whether an image map, by name, is rendered through some image that uses it; shared by all its areas. */
+ readonly imageMaps: Map
+ readonly styles: Map>
+ readonly names: Map
+ readonly descriptions: Map
+ /** Text alternative per element as a descendant of something being named (the standard recursive traversal), which every enclosing content-named ancestor repeats. */
+ readonly descendantTexts: Map
+ /** Name-from-content text per element for the standard traversal. */
+ readonly contentTexts: Map
+ /** Text alternative per aria-labelledby / aria-describedby target, which many elements can reference. */
+ readonly referenceTexts: Map
+ /** Generated ::before and ::after text per element, read at most once. */
+ readonly generated: Map
+ /** Per tree scope (document or shadow root): whether any of its sheets can define generated content. */
+ readonly generatedScopes: Map
+ /** Elements already placed in the tree; aria-owns re-parenting must not place one twice. */
+ readonly placed: Set
+ /** aria-owns targets of each owner in the walked subtree, in attribute order. */
+ readonly owned: Map
+ /** Every owned element, so it is skipped at its DOM position and placed under its owner. */
+ readonly claimed: Set
+}
+
+/** Where a name came from decides whether text below the node is repeated and whether title is free for the description. */
+export type NameSource = 'none' | 'reference' | 'label' | 'native' | 'content' | 'title' | 'placeholder'
+
+export interface NameResult {
+ readonly text: string
+ readonly source: NameSource
+}
+
+export function createContext(): SnapshotContext {
+ return {
+ roles: new Map(),
+ hidden: new Map(),
+ imageMaps: new Map(),
+ styles: new Map(),
+ names: new Map(),
+ descriptions: new Map(),
+ descendantTexts: new Map(),
+ contentTexts: new Map(),
+ referenceTexts: new Map(),
+ generated: new Map(),
+ generatedScopes: new Map(),
+ placed: new Set(),
+ owned: new Map(),
+ claimed: new Set(),
+ }
+}
+
+export function roleOf(element: Element, context: SnapshotContext): string {
+ let role = context.roles.get(element)
+ if (role === undefined) {
+ // The role of a cell depends on its table's, which every cell would
+ // otherwise recompute; the lookup goes through this cache.
+ role = computeRole(element, (owner) => roleOf(owner, context))
+ context.roles.set(element, role)
+ }
+ return role
+}
+
+/**
+ * The computed style of an element, or of one of its pseudo-elements when
+ * `pseudo` is "::before" or "::after". Null when the element has no view.
+ */
+export function styleOf(element: Element, context: SnapshotContext, pseudo = ''): CSSStyleDeclaration | null {
+ let perPseudo = context.styles.get(element)
+ if (!perPseudo) {
+ perPseudo = new Map()
+ context.styles.set(element, perPseudo)
+ }
+ let style = perPseudo.get(pseudo)
+ if (style === undefined) {
+ const view = element.ownerDocument.defaultView
+ style = view ? view.getComputedStyle(element, pseudo || null) : null
+ perPseudo.set(pseudo, style)
+ }
+ return style
+}
diff --git a/javascript/atoms/typescript/core/accessibility/hidden.ts b/javascript/atoms/typescript/core/accessibility/hidden.ts
new file mode 100644
index 0000000000000..885ab0e6110de
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/hidden.ts
@@ -0,0 +1,242 @@
+// 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.
+
+// "Hidden" in the accname sense (https://w3c.github.io/accname/#dfn-hidden):
+// an element that is not rendered, or that has aria-hidden=true on itself or
+// an ancestor. This is deliberately not bot.dom.isShown. WebDriver's
+// displayedness also prunes zero-size, transparent and off-screen content,
+// which assistive technology does reach; the "visually hidden" CSS pattern
+// that screen-reader-only text relies on is built on exactly that difference.
+//
+// Ancestry follows the flat tree (slots and shadow hosts), because that is
+// what gets rendered, not the light DOM.
+
+import { SnapshotContext, styleOf } from './context.js'
+
+const DOCUMENT_NODE = 9
+const DOCUMENT_FRAGMENT_NODE = 11
+
+/**
+ * What an element does to its own visibility, with no regard to ancestors:
+ * rendered it has a box
+ * self its own box is invisible but a descendant may set itself visible
+ * again (visibility is inherited, not a subtree switch)
+ * contents its own box is rendered but nothing below it is
+ * (content-visibility: hidden, which hidden="until-found" uses)
+ * subtree neither it nor anything below it is rendered
+ */
+export type HiddenState = 'rendered' | 'self' | 'contents' | 'subtree'
+
+// The flat-tree parent of a node, or one of two terminals.
+const NOT_RENDERED: unique symbol = Symbol('not rendered')
+type FlatTreeParent = Element | null | typeof NOT_RENDERED
+
+function isAriaHidden(element: Element): boolean {
+ const value = element.getAttribute('aria-hidden')
+ return value !== null && value.trim().toLowerCase() === 'true'
+}
+
+// The UA stylesheet gives every area display: none; what renders it is the
+// image whose usemap points at its map. So an area is as hidden as that image.
+// The document-wide image lookup is done once per map name and snapshot, since
+// every area of a map shares the answer.
+function areaState(area: Element, context: SnapshotContext): HiddenState {
+ const map = area.closest('map')
+ const name = map ? map.getAttribute('name') : null
+ if (!name) {
+ return 'subtree'
+ }
+ const known = context.imageMaps.get(name)
+ if (known !== undefined) {
+ return known
+ }
+ let state: HiddenState = 'subtree'
+ const images = area.ownerDocument.querySelectorAll('img[usemap]')
+ for (let i = 0; i < images.length; i++) {
+ const image = images[i]
+ const usemap = (image.getAttribute('usemap') || '').replace(/^#/, '')
+ if (usemap === name && hiddenState(image, context) === 'rendered' && !isHiddenByAncestor(image, context)) {
+ state = 'rendered'
+ break
+ }
+ }
+ context.imageMaps.set(name, state)
+ return state
+}
+
+export function isClosedDetails(element: Element): boolean {
+ return element.localName === 'details' && !element.hasAttribute('open')
+}
+
+/**
+ * The parent that renders this node: its slot when it is assigned to one, the
+ * host when it sits directly in a shadow root, otherwise its DOM parent. Null
+ * at the document; NOT_RENDERED for nodes the flat tree leaves out (a child of
+ * a shadow host that no slot took, fallback content of a slot that has
+ * assigned nodes, anything detached or inside an inert fragment).
+ *
+ * A closed shadow root cannot be seen from outside: `shadowRoot` and
+ * `assignedSlot` are both null for its host's children, so those read as
+ * ordinary children of the host and are treated as rendered.
+ */
+// An HTML slot element; an element merely named slot in another namespace
+// has no assigned nodes to ask for.
+function isSlot(element: Element): element is HTMLSlotElement {
+ return element.localName === 'slot' && 'assignedNodes' in element
+}
+
+function flatTreeParent(node: Element): FlatTreeParent {
+ const parent = node.parentNode
+ if (!parent) {
+ return NOT_RENDERED
+ }
+ if (node.assignedSlot) {
+ return node.assignedSlot
+ }
+ if (parent.nodeType === DOCUMENT_FRAGMENT_NODE) {
+ const host = (parent as ShadowRoot).host
+ return host ? host : NOT_RENDERED
+ }
+ if (parent.nodeType === DOCUMENT_NODE) {
+ return null
+ }
+ const parentElement = parent as Element
+ if (parentElement.shadowRoot) {
+ return NOT_RENDERED
+ }
+ if (isSlot(parentElement) && parentElement.assignedNodes().length > 0) {
+ return NOT_RENDERED
+ }
+ return parentElement
+}
+
+/**
+ * The nodes rendered as this element's children, in render order: the shadow
+ * root's children for a host, the assigned nodes (or the fallback while none
+ * are assigned) for a slot, otherwise the DOM children. Nested slots are
+ * flattened away, so a slot element never appears in the result.
+ *
+ * A closed shadow root cannot be seen, so its host's light children are
+ * returned as if they rendered; see flatTreeParent.
+ */
+export function flatTreeChildren(element: Element): ArrayLike {
+ if (isSlot(element)) {
+ return element.assignedNodes({ flatten: true })
+ }
+ if (element.shadowRoot) {
+ return element.shadowRoot.childNodes
+ }
+ return element.childNodes
+}
+
+/**
+ * Children of a closed details element are not rendered, apart from its first
+ * summary. The browser does this through a UA shadow tree that is closed to
+ * script, so it has to be known here rather than read from the DOM.
+ */
+export function isCollapsedDetailsContent(child: Element, parent: Element): boolean {
+ if (!isClosedDetails(parent)) {
+ return false
+ }
+ for (let sibling = parent.firstElementChild; sibling; sibling = sibling.nextElementSibling) {
+ if (sibling.localName === 'summary') {
+ return sibling !== child
+ }
+ }
+ return true
+}
+
+/**
+ * See HiddenState. Ancestors are not consulted; isHidden does that. Cached
+ * per element in the context: the walk, name-from-content and reference
+ * resolution all ask about the same elements.
+ */
+export function hiddenState(element: Element, context: SnapshotContext): HiddenState {
+ let state = context.hidden.get(element)
+ if (state === undefined) {
+ state = computeHiddenState(element, context)
+ context.hidden.set(element, state)
+ }
+ return state
+}
+
+function computeHiddenState(element: Element, context: SnapshotContext): HiddenState {
+ // Inert content is still painted, but browsers leave it out of the
+ // accessibility tree along with everything it contains.
+ if (isAriaHidden(element) || element.hasAttribute('inert')) {
+ return 'subtree'
+ }
+ // With scripting enabled a noscript element renders nothing, but Chromium
+ // decides that in layout rather than in its UA stylesheet, so its computed
+ // display still reads inline. This atom only ever runs as script.
+ if (element.localName === 'noscript') {
+ return 'subtree'
+ }
+ if (element.localName === 'area') {
+ return areaState(element, context)
+ }
+ const style = styleOf(element, context)
+ // No view (an inert template fragment) and a detached element (empty
+ // computed values) are both unrendered.
+ if (!style || style.display === '' || style.display === 'none') {
+ return 'subtree'
+ }
+ const visibility = style.visibility
+ const invisible = visibility === 'hidden' || visibility === 'collapse'
+ const contentsSkipped = style.getPropertyValue('content-visibility') === 'hidden'
+ if (invisible && contentsSkipped) {
+ return 'subtree'
+ }
+ if (contentsSkipped) {
+ return 'contents'
+ }
+ if (invisible) {
+ return 'self'
+ }
+ return 'rendered'
+}
+
+/** Whether an ancestor in the flat tree hides this element, whatever the element does itself. */
+export function isHiddenByAncestor(element: Element, context: SnapshotContext): boolean {
+ let child = element
+ for (;;) {
+ const parent = flatTreeParent(child)
+ if (parent === NOT_RENDERED) {
+ return true
+ }
+ if (parent === null) {
+ return false
+ }
+ const state = hiddenState(parent, context)
+ if (isCollapsedDetailsContent(child, parent) || state === 'subtree' || state === 'contents') {
+ return true
+ }
+ child = parent
+ }
+}
+
+/** Whether the element is hidden in the accname sense. */
+export function isHidden(element: Element, context: SnapshotContext): boolean {
+ const own = hiddenState(element, context)
+ if (own === 'self' || own === 'subtree') {
+ return true
+ }
+ // Visibility is inherited, so the element's own computed value above already
+ // accounts for ancestors that set it; only subtree-wide hiding is checked
+ // on the way up.
+ return isHiddenByAncestor(element, context)
+}
diff --git a/javascript/atoms/typescript/core/accessibility/roles.ts b/javascript/atoms/typescript/core/accessibility/roles.ts
new file mode 100644
index 0000000000000..006ad430ce510
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/roles.ts
@@ -0,0 +1,410 @@
+// 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.
+
+// Role computation: the explicit role attribute with the WAI-ARIA 1.2 fallback
+// and conflict rules, then the html-aam implicit mapping. Where html-aam says
+// an element has "no corresponding role" this reports `generic`, so the
+// pruning rule in ADR 18205 treats it like a div. Two rows deliberately
+// diverge from html-aam towards what engines expose, and the roles test page
+// pins them: input type=password is a textbox and summary is a button.
+
+// WAI-ARIA 1.2 roles authors may use (abstract roles left out), plus the
+// graphics module roles that svg maps to.
+const CONCRETE_ROLES: ReadonlySet = new Set([
+ 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
+ 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
+ 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
+ 'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group', 'heading', 'img',
+ 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math',
+ 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter',
+ 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar',
+ 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search',
+ 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'strong', 'subscript',
+ 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time',
+ 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem',
+ 'graphics-document', 'graphics-object', 'graphics-symbol',
+])
+
+// WAI-ARIA 1.2 §6.4, global states and properties, including the four the
+// spec deprecates as globals but still lists. Their presence stops
+// none/presentation from applying (§9.1).
+const GLOBAL_ARIA_ATTRIBUTES: ReadonlyArray = [
+ 'aria-atomic', 'aria-busy', 'aria-controls', 'aria-current', 'aria-describedby',
+ 'aria-description', 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
+ 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
+ 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
+ 'aria-relevant', 'aria-roledescription',
+]
+
+// Elements that take focus on their own, when not disabled.
+const NATIVELY_FOCUSABLE: ReadonlySet = new Set(['button', 'iframe', 'input', 'select', 'summary', 'textarea'])
+
+// Not a WAI-ARIA role: the token ADR 18205 uses for frame leaves, which the
+// bindings replace with the child document's tree.
+const FRAME_ELEMENTS: ReadonlySet = new Set(['frame', 'iframe'])
+
+const SECTIONING_CONTENT: ReadonlySet = new Set(['article', 'aside', 'nav', 'section'])
+// html-aam: a header or footer is a landmark only outside these elements and
+// outside elements with these roles.
+const HEADER_FOOTER_SCOPES: ReadonlySet = new Set(['article', 'aside', 'main', 'nav', 'section'])
+const HEADER_FOOTER_SCOPE_ROLES: ReadonlySet = new Set(['article', 'complementary', 'main', 'navigation', 'region'])
+const LIST_PARENTS: ReadonlySet = new Set(['menu', 'ol', 'ul'])
+
+export function isPresentational(role: string): boolean {
+ return role === 'none' || role === 'presentation'
+}
+
+/**
+ * How the role of another element is found when one element's role depends
+ * on it (a cell on its table, an li on its list). The snapshot context
+ * passes its cached lookup so the owner is computed once for all of them.
+ */
+export type RoleLookup = (element: Element) => string
+
+/**
+ * The first token of the role attribute that names a role authors may use,
+ * or null when there is none. Tokens match ASCII case-insensitively, which is
+ * what engines do.
+ */
+export function explicitRole(element: Element): string | null {
+ const attribute = element.getAttribute('role')
+ if (!attribute) {
+ return null
+ }
+ for (const token of attribute.trim().toLowerCase().split(/\s+/)) {
+ if (CONCRETE_ROLES.has(token)) {
+ return token
+ }
+ }
+ return null
+}
+
+function hasGlobalAriaAttribute(element: Element): boolean {
+ return GLOBAL_ARIA_ATTRIBUTES.some((name) => element.hasAttribute(name))
+}
+
+// "Focusable" in the §9.1 sense: the element can take focus at all, including
+// through a negative tabindex. This is narrower than core/focusable.ts, which
+// answers WebDriver's question and counts every anchor and label. The states
+// module uses the same notion for what an ancestor's aria-disabled reaches.
+export function canTakeFocus(element: Element): boolean {
+ const name = element.localName
+ if (NATIVELY_FOCUSABLE.has(name)) {
+ // A disabled control or a hidden input cannot take focus, whatever its tabindex.
+ if (name === 'input' && (element as HTMLInputElement).type === 'hidden') {
+ return false
+ }
+ return !('disabled' in element && (element as HTMLInputElement).disabled)
+ }
+ const tabindex = element.getAttribute('tabindex')
+ if (tabindex !== null && /^\s*[-+]?\d+\s*$/.test(tabindex)) {
+ return true
+ }
+ if (name === 'a' || name === 'area') {
+ return element.hasAttribute('href')
+ }
+ return (element as HTMLElement).isContentEditable === true
+}
+
+// html-aam makes some roles depend on whether the element has an accessible
+// name (section, form, aside inside sectioning content). Only the author
+// sources that can produce one are checked here: aria-label, an
+// aria-labelledby that resolves to something with a text alternative, or a
+// title. The full name computation is not what those rows ask for.
+function hasAuthorName(element: Element): boolean {
+ const label = element.getAttribute('aria-label')
+ if (label !== null && label.trim() !== '') {
+ return true
+ }
+ const labelledBy = element.getAttribute('aria-labelledby')
+ if (labelledBy !== null && labelledBy.trim() !== '') {
+ const root = element.getRootNode() as Document | ShadowRoot
+ for (const id of labelledBy.trim().split(/\s+/)) {
+ const target = root.getElementById(id)
+ if (target && hasTextAlternative(target)) {
+ return true
+ }
+ }
+ }
+ const title = element.getAttribute('title')
+ return title !== null && title.trim() !== ''
+}
+
+// The attributes accname reads for the text alternative of an element inside
+// a referenced label when it has no text of its own: an image's alt, a label
+// or tooltip, a control's value.
+const ALTERNATIVE_ATTRIBUTES = ['aria-label', 'alt', 'title', 'value']
+
+// Whether a referenced label can contribute any text: its own text content,
+// or an alternative on it or on an element below it.
+function hasTextAlternative(target: Element): boolean {
+ if ((target.textContent || '').trim() !== '') {
+ return true
+ }
+ const elements = [target, ...Array.from(target.querySelectorAll('*'))]
+ return elements.some((candidate) =>
+ ALTERNATIVE_ATTRIBUTES.some((attribute) => (candidate.getAttribute(attribute) || '').trim() !== ''),
+ )
+}
+
+// WAI-ARIA 1.2 §9.1: when an element with required owned elements is
+// presentational, so are those owned elements.
+function ownerIsPresentational(owner: Element | null, lookup: RoleLookup): boolean {
+ return owner !== null && isPresentational(lookup(owner))
+}
+
+function hasAncestor(element: Element, names: ReadonlySet, roles: ReadonlySet | null = null): boolean {
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
+ if (names.has(parent.localName)) {
+ return true
+ }
+ if (roles !== null) {
+ const role = explicitRole(parent)
+ if (role !== null && roles.has(role)) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+function inputRole(input: HTMLInputElement): string {
+ // The IDL property already folds unknown and missing types to "text".
+ const type = input.type
+ const hasList = input.hasAttribute('list')
+ switch (type) {
+ case 'button':
+ case 'image':
+ case 'reset':
+ case 'submit':
+ return 'button'
+ case 'checkbox':
+ return 'checkbox'
+ case 'radio':
+ return 'radio'
+ case 'range':
+ return 'slider'
+ case 'number':
+ return 'spinbutton'
+ case 'search':
+ return hasList ? 'combobox' : 'searchbox'
+ case 'email':
+ case 'tel':
+ case 'text':
+ case 'url':
+ return hasList ? 'combobox' : 'textbox'
+ case 'password':
+ // html-aam: no corresponding role. Engines expose a textbox, and
+ // dropping password fields from the tree would hide a control users act on.
+ return 'textbox'
+ default:
+ // color, date, datetime-local, file, hidden, month, time, week.
+ return 'generic'
+ }
+}
+
+function tableCellRole(cell: Element, lookup: RoleLookup): string {
+ const table = cell.closest('table')
+ if (!table) {
+ return 'cell'
+ }
+ const tableRole = lookup(table)
+ if (isPresentational(tableRole)) {
+ return 'generic'
+ }
+ return tableRole === 'grid' || tableRole === 'treegrid' ? 'gridcell' : 'cell'
+}
+
+function tableHeaderRole(header: Element, lookup: RoleLookup): string {
+ if (ownerIsPresentational(header.closest('table'), lookup)) {
+ return 'generic'
+ }
+ const scope = (header.getAttribute('scope') || '').toLowerCase()
+ return scope === 'row' || scope === 'rowgroup' ? 'rowheader' : 'columnheader'
+}
+
+function tablePartRole(part: Element, role: string, lookup: RoleLookup): string {
+ return ownerIsPresentational(part.closest('table'), lookup) ? 'generic' : role
+}
+
+/**
+ * The html-aam implicit role of an element, with no regard to its role
+ * attribute. `lookup` finds the role of an element this one depends on.
+ */
+export function implicitRole(element: Element, lookup: RoleLookup = computeRole): string {
+ switch (element.localName) {
+ case 'a':
+ case 'area':
+ return element.hasAttribute('href') ? 'link' : 'generic'
+ case 'address':
+ case 'details':
+ case 'fieldset':
+ case 'hgroup':
+ case 'optgroup':
+ return 'group'
+ case 'article':
+ return 'article'
+ case 'aside':
+ if (!hasAncestor(element, SECTIONING_CONTENT)) {
+ return 'complementary'
+ }
+ return hasAuthorName(element) ? 'complementary' : 'generic'
+ case 'blockquote':
+ return 'blockquote'
+ case 'button':
+ return 'button'
+ case 'caption':
+ return 'caption'
+ case 'code':
+ return 'code'
+ case 'datalist':
+ return 'listbox'
+ case 'dd':
+ return 'definition'
+ case 'del':
+ return 'deletion'
+ case 'dfn':
+ case 'dt':
+ return 'term'
+ case 'dialog':
+ return 'dialog'
+ case 'em':
+ return 'emphasis'
+ case 'figure':
+ return 'figure'
+ case 'footer':
+ return hasAncestor(element, HEADER_FOOTER_SCOPES, HEADER_FOOTER_SCOPE_ROLES) ? 'generic' : 'contentinfo'
+ case 'form':
+ // html-aam: a landmark only with an accessible name, otherwise no role.
+ return hasAuthorName(element) ? 'form' : 'generic'
+ case 'frame':
+ case 'iframe':
+ return 'iframe'
+ case 'h1':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ return 'heading'
+ case 'header':
+ return hasAncestor(element, HEADER_FOOTER_SCOPES, HEADER_FOOTER_SCOPE_ROLES) ? 'generic' : 'banner'
+ case 'hr':
+ return 'separator'
+ case 'html':
+ return 'document'
+ case 'img':
+ if (element.getAttribute('alt') === '' && !hasGlobalAriaAttribute(element) && !canTakeFocus(element)) {
+ return 'presentation'
+ }
+ return 'img'
+ case 'input':
+ return inputRole(element as HTMLInputElement)
+ case 'ins':
+ return 'insertion'
+ case 'li': {
+ const parent = element.parentElement
+ if (!parent || !LIST_PARENTS.has(parent.localName) || ownerIsPresentational(parent, lookup)) {
+ return 'generic'
+ }
+ return 'listitem'
+ }
+ case 'main':
+ return 'main'
+ case 'math':
+ return 'math'
+ case 'menu':
+ case 'ol':
+ case 'ul':
+ return 'list'
+ case 'meter':
+ return 'meter'
+ case 'nav':
+ return 'navigation'
+ case 'option':
+ return 'option'
+ case 'output':
+ return 'status'
+ case 'p':
+ return 'paragraph'
+ case 'progress':
+ return 'progressbar'
+ case 'search':
+ return 'search'
+ case 'section':
+ return hasAuthorName(element) ? 'region' : 'generic'
+ case 'select': {
+ const select = element as HTMLSelectElement
+ return select.multiple || select.size > 1 ? 'listbox' : 'combobox'
+ }
+ case 'strong':
+ return 'strong'
+ case 'sub':
+ return 'subscript'
+ case 'summary':
+ // html-aam: no corresponding role. Engines expose the disclosure control
+ // as a button, and that is what users click.
+ return 'button'
+ case 'sup':
+ return 'superscript'
+ case 'svg':
+ return 'graphics-document'
+ case 'table':
+ return 'table'
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ return tablePartRole(element, 'rowgroup', lookup)
+ case 'td':
+ return tableCellRole(element, lookup)
+ case 'textarea':
+ return 'textbox'
+ case 'th':
+ return tableHeaderRole(element, lookup)
+ case 'time':
+ return 'time'
+ case 'tr':
+ return tablePartRole(element, 'row', lookup)
+ default:
+ return 'generic'
+ }
+}
+
+/**
+ * The role the snapshot reports for an element: the explicit role when it
+ * names a usable role and is not a presentational role on an element that
+ * cannot be presentational (WAI-ARIA 1.2 §9.1), otherwise the implicit one.
+ * `lookup` finds the role of an element this one depends on; without one,
+ * it is computed afresh.
+ */
+export function computeRole(element: Element, lookup: RoleLookup = computeRole): string {
+ if (FRAME_ELEMENTS.has(element.localName)) {
+ // The token is how the bindings find the frames whose documents they
+ // stitch in, so an explicit role (ARIA in HTML allows application,
+ // document, img and the presentational pair) never replaces it; the
+ // embedded document is exposed whatever the frame element is called.
+ return 'iframe'
+ }
+ const explicit = explicitRole(element)
+ if (explicit === null) {
+ return implicitRole(element, lookup)
+ }
+ if (isPresentational(explicit) && (canTakeFocus(element) || hasGlobalAriaAttribute(element))) {
+ return implicitRole(element, lookup)
+ }
+ return explicit
+}
diff --git a/javascript/atoms/typescript/core/accessibility/snapshot.ts b/javascript/atoms/typescript/core/accessibility/snapshot.ts
new file mode 100644
index 0000000000000..f7d78f23e7401
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/snapshot.ts
@@ -0,0 +1,61 @@
+// 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.
+
+import { isElement } from '../element.js'
+import { createContext } from './context.js'
+import { buildTree } from './tree.js'
+import type { AccessibilityNode } from './types.js'
+
+const DOCUMENT_NODE = 9
+
+function isDocument(node: unknown): node is Document {
+ return !!node && typeof node === 'object' && (node as Node).nodeType === DOCUMENT_NODE
+}
+
+// A root that is not attached cannot answer "hidden" or "focused" questions, and
+// a non-element root has no tree to walk, so both are refused up front rather
+// than producing a partial snapshot the caller cannot distinguish from a real one.
+function resolveRoot(root: Element | Document | null | undefined): Element {
+ if (root === undefined || root === null) {
+ return document.documentElement
+ }
+ if (isDocument(root)) {
+ return root.documentElement
+ }
+ if (!isElement(root)) {
+ throw new Error('accessibilitySnapshot: root must be an Element or a Document')
+ }
+ if (!root.isConnected) {
+ throw new Error('accessibilitySnapshot: root is detached from its document')
+ }
+ return root
+}
+
+/**
+ * Computes an accessibility snapshot of the subtree rooted at `root`, or of the
+ * whole document when no root is given.
+ *
+ * The output is *computed* by script, not read from the browser's own
+ * accessibility tree. Frames are reported as `iframe` leaves and never
+ * descended into; callers stitch child-document snapshots in themselves.
+ *
+ */
+function accessibilitySnapshot(root?: Element | Document | null): AccessibilityNode {
+ return buildTree(resolveRoot(root), createContext())
+}
+
+export default accessibilitySnapshot
diff --git a/javascript/atoms/typescript/core/accessibility/states.ts b/javascript/atoms/typescript/core/accessibility/states.ts
new file mode 100644
index 0000000000000..fdfe75c59df69
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/states.ts
@@ -0,0 +1,233 @@
+// 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.
+
+import { isEnabled } from '../enabled.js'
+import { canTakeFocus } from './roles.js'
+import type { StateValue } from './types.js'
+import { embeddedValue } from './value.js'
+
+// The `states` map and `value` field of ADR 18205 §2. Which keys appear:
+//
+// checked whenever the role supports it; "mixed" where the role allows it
+// pressed when the element sets aria-pressed: a button without it is not
+// a toggle button, so reporting pressed=false would mislead
+// expanded when the element sets aria-expanded, and on a summary from its
+// details' open state
+// selected always for option, tab and treeitem, where selection is part of
+// the role; for gridcell, row and headers only when the element
+// sets aria-selected, since most grids are not selectable
+// level always for headings (tag, aria-level, or ARIA's default of 2);
+// elsewhere when the element sets aria-level
+// disabled true only when disabled, natively or through aria-disabled on
+// the element, or on an ancestor when the element can take focus
+// (WAI-ARIA 1.2: the state reaches focusable descendants)
+// focused true only on the focused element
+//
+// For pressed, expanded and the grid parts' selected, "undefined" and an
+// empty value mean the attribute is not set: ARIA defines undefined as "not
+// a toggle", "not expandable", "not selectable".
+//
+// Adding a key is additive under the ADR's compatibility rule; the states
+// page pins the current set.
+
+const CHECKED_ROLES: ReadonlySet = new Set(['checkbox', 'menuitemcheckbox', 'menuitemradio', 'radio', 'switch'])
+const MIXED_ALLOWED: ReadonlySet = new Set(['checkbox', 'menuitemcheckbox'])
+const ALWAYS_SELECTED_ROLES: ReadonlySet = new Set(['option', 'tab', 'treeitem'])
+const SELECTABLE_ROLES: ReadonlySet = new Set(['columnheader', 'gridcell', 'row', 'rowheader'])
+const LEVEL_ROLES: ReadonlySet = new Set(['comment', 'listitem', 'row', 'tablist', 'treeitem'])
+const VALUE_ROLES: ReadonlySet = new Set([
+ 'combobox', 'meter', 'progressbar', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'textbox',
+])
+// The elements core/enabled.ts can report as disabled; for anything else it
+// answers enabled without looking, so it is not asked.
+const DISABLEABLE_ELEMENTS: ReadonlySet = new Set(['button', 'input', 'optgroup', 'option', 'select', 'textarea'])
+
+function token(element: Element, name: string): string | null {
+ const value = element.getAttribute(name)
+ return value === null ? null : value.trim().toLowerCase()
+}
+
+// A token whose "undefined" and empty values count as the attribute being absent.
+function optionalToken(element: Element, name: string): string | null {
+ const value = token(element, name)
+ return value === '' || value === 'undefined' ? null : value
+}
+
+function tristate(element: Element, name: string, allowMixed: boolean): StateValue {
+ const value = token(element, name)
+ if (value === 'true') {
+ return true
+ }
+ if (value === 'mixed' && allowMixed) {
+ return 'mixed'
+ }
+ return false
+}
+
+function checked(element: Element, role: string): StateValue {
+ if (element.localName === 'input') {
+ const input = element as HTMLInputElement
+ if (input.type === 'checkbox' || input.type === 'radio') {
+ return input.indeterminate && MIXED_ALLOWED.has(role) ? 'mixed' : input.checked
+ }
+ }
+ return tristate(element, 'aria-checked', MIXED_ALLOWED.has(role))
+}
+
+function expanded(element: Element): boolean | null {
+ const value = optionalToken(element, 'aria-expanded')
+ if (value !== null) {
+ return value === 'true'
+ }
+ if (element.localName === 'summary') {
+ const details = element.parentElement
+ if (details && details.localName === 'details') {
+ return details.hasAttribute('open')
+ }
+ }
+ return null
+}
+
+function selected(element: Element, role: string): boolean | null {
+ if (element.localName === 'option' && role === 'option') {
+ return (element as HTMLOptionElement).selected
+ }
+ if (ALWAYS_SELECTED_ROLES.has(role)) {
+ return token(element, 'aria-selected') === 'true'
+ }
+ if (SELECTABLE_ROLES.has(role)) {
+ const value = optionalToken(element, 'aria-selected')
+ return value === null ? null : value === 'true'
+ }
+ return null
+}
+
+function ariaLevel(element: Element): number | null {
+ const value = element.getAttribute('aria-level')
+ if (value === null || !/^\s*\d+\s*$/.test(value)) {
+ return null
+ }
+ const level = parseInt(value, 10)
+ return level >= 1 ? level : null
+}
+
+function level(element: Element, role: string): number | null {
+ if (role === 'heading') {
+ const explicit = ariaLevel(element)
+ if (explicit !== null) {
+ return explicit
+ }
+ const tag = /^h([1-6])$/.exec(element.localName)
+ return tag ? Number(tag[1]) : 2
+ }
+ return LEVEL_ROLES.has(role) ? ariaLevel(element) : null
+}
+
+/** Whether the element itself sets aria-disabled=true. */
+export function setsAriaDisabled(element: Element): boolean {
+ return token(element, 'aria-disabled') === 'true'
+}
+
+// The DOM parent, or the host for a shadow root's child: aria-disabled on
+// a host reaches into its shadow tree.
+function parentOrHost(element: Element): Element | null {
+ if (element.parentElement) {
+ return element.parentElement
+ }
+ return (element.getRootNode() as ShadowRoot).host || null
+}
+
+/**
+ * Whether an ancestor sets aria-disabled. The walk threads that down, so
+ * this check is only for a root or an owned element, whose ancestors the
+ * walk has not seen.
+ */
+export function isDisabledByAncestor(element: Element): boolean {
+ for (let current = parentOrHost(element); current; current = parentOrHost(current)) {
+ if (setsAriaDisabled(current)) {
+ return true
+ }
+ }
+ return false
+}
+
+// An ancestor's aria-disabled reaches the descendants that can take focus
+// (WAI-ARIA 1.2), not the headings and paragraphs between them.
+function disabled(element: Element, disabledByAncestor: boolean): boolean {
+ if (setsAriaDisabled(element) || (disabledByAncestor && canTakeFocus(element))) {
+ return true
+ }
+ return DISABLEABLE_ELEMENTS.has(element.localName) && !isEnabled(element)
+}
+
+// The body is the default active element and is not "focused" in any sense a
+// user would recognise. Focus inside a shadow tree is known to that tree's
+// root; the document only knows the host, which is not itself focused while
+// something it can see inside its shadow root is.
+function focused(element: Element): boolean {
+ const doc = element.ownerDocument
+ if (element === doc.body || element === doc.documentElement) {
+ return false
+ }
+ const root = element.getRootNode() as Document | ShadowRoot
+ if (root.activeElement !== element) {
+ return false
+ }
+ return !(element.shadowRoot && element.shadowRoot.activeElement)
+}
+
+/**
+ * The states map for an element with the given role; {} when there are none.
+ * `disabledByAncestor` is whether an ancestor sets aria-disabled, which the
+ * walk knows without climbing.
+ */
+export function computeStates(element: Element, role: string, disabledByAncestor: boolean): Record {
+ const states: Record = {}
+ if (CHECKED_ROLES.has(role)) {
+ states.checked = checked(element, role)
+ }
+ if (role === 'button') {
+ const pressed = optionalToken(element, 'aria-pressed')
+ if (pressed !== null) {
+ states.pressed = tristate(element, 'aria-pressed', true)
+ }
+ }
+ const isExpanded = expanded(element)
+ if (isExpanded !== null) {
+ states.expanded = isExpanded
+ }
+ const isSelected = selected(element, role)
+ if (isSelected !== null) {
+ states.selected = isSelected
+ }
+ const headingLevel = level(element, role)
+ if (headingLevel !== null) {
+ states.level = headingLevel
+ }
+ if (disabled(element, disabledByAncestor)) {
+ states.disabled = true
+ }
+ if (focused(element)) {
+ states.focused = true
+ }
+ return states
+}
+
+/** The current value for textbox, searchbox, combobox and range roles; null when the field is absent. */
+export function computeValue(element: Element, role: string): string | null {
+ return VALUE_ROLES.has(role) ? embeddedValue(element, role) : null
+}
diff --git a/javascript/atoms/typescript/core/accessibility/tree.ts b/javascript/atoms/typescript/core/accessibility/tree.ts
new file mode 100644
index 0000000000000..a1a13f899ce3a
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/tree.ts
@@ -0,0 +1,283 @@
+// 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.
+
+import { computeDescription, computeName } from './accname.js'
+import { roleOf, SnapshotContext } from './context.js'
+import { flatTreeChildren, hiddenState, isClosedDetails, isCollapsedDetailsContent, isHiddenByAncestor } from './hidden.js'
+import { isPresentational } from './roles.js'
+import { computeStates, computeValue, isDisabledByAncestor, setsAriaDisabled } from './states.js'
+import type { AccessibilityNode } from './types.js'
+
+const ELEMENT_NODE = 1
+const TEXT_NODE = 3
+
+// WAI-ARIA 1.2 roles with "Children Presentational: True" (plus the graphics
+// module's symbol). Their content only contributes to their name, never to
+// the tree.
+const CHILDREN_PRESENTATIONAL: ReadonlySet = new Set([
+ 'button', 'checkbox', 'graphics-symbol', 'img', 'menuitemcheckbox', 'menuitemradio', 'meter',
+ 'option', 'progressbar', 'radio', 'scrollbar', 'separator', 'slider', 'switch', 'tab',
+])
+
+// Elements whose DOM children are not rendered content, whatever role they
+// end up with: a frame's children are fallback for a browsing context only
+// the bindings can reach (the ADR's iframe leaf), a textarea's text child is
+// its default value, and media fallback shows only when the media cannot.
+const LEAF_ELEMENTS: ReadonlySet = new Set(['audio', 'frame', 'iframe', 'textarea', 'video'])
+
+function textLeaf(text: string): AccessibilityNode {
+ // No `element` or `value` key at all rather than undefined, so the
+ // serialized shape matches the schema literally.
+ return { role: 'text', name: text, description: '', states: {}, children: [] }
+}
+
+function elementNode(
+ element: Element,
+ role: string,
+ name: string,
+ context: SnapshotContext,
+ disabledByAncestor: boolean,
+): AccessibilityNode {
+ const node: AccessibilityNode = {
+ role,
+ name,
+ description: computeDescription(element, context),
+ states: computeStates(element, role, disabledByAncestor),
+ element,
+ children: [],
+ }
+ const value = computeValue(element, role)
+ if (value !== null) {
+ node.value = value
+ }
+ return node
+}
+
+function normalizeText(text: string): string {
+ return text.replace(/\s+/g, ' ').trim()
+}
+
+// ADR 18205 §2: an element produces a node when its role is not generic,
+// none or presentation, or it has a name.
+function producesNode(role: string, name: string): boolean {
+ return !(role === 'generic' || isPresentational(role)) || name !== ''
+}
+
+function isLeaf(element: Element, role: string): boolean {
+ return LEAF_ELEMENTS.has(element.localName) || CHILDREN_PRESENTATIONAL.has(role)
+}
+
+// What the walk carries down from the ancestors of the element it visits:
+// whether their visibility hides text, whether an ancestor's name consumed
+// the text, and whether an ancestor sets aria-disabled.
+interface Inherited {
+ readonly textHidden: boolean
+ readonly textConsumed: boolean
+ readonly disabled: boolean
+}
+
+/**
+ * Appends the node(s) for one element to `out`: its own node with its
+ * children, or, when it produces no node (pruned role, visibility-hidden),
+ * its children in its place so hoisting keeps order.
+ *
+ * `textHidden` is true while inside an element whose visibility hides its
+ * text: visibility is inherited, so the text of such an element and of
+ * descendants that do not set themselves visible again is not rendered.
+ * `textConsumed` is true below a node whose name was computed from its
+ * content: that text already appears as the name and is not repeated as
+ * leaves, while the element structure below is still reported. `disabled`
+ * is true below an element that sets aria-disabled, which the states module
+ * applies to the descendants that can take focus.
+ */
+function visitElement(element: Element, context: SnapshotContext, above: Inherited, out: AccessibilityNode[]): void {
+ if (context.placed.has(element)) {
+ return
+ }
+ context.placed.add(element)
+ const inherited: Inherited = above.disabled || !setsAriaDisabled(element) ? above : { ...above, disabled: true }
+
+ const domParent = element.parentElement
+ if (domParent && isCollapsedDetailsContent(element, domParent)) {
+ return
+ }
+ const hidden = hiddenState(element, context)
+ if (hidden === 'subtree') {
+ return
+ }
+ if (hidden === 'self') {
+ collectChildren(element, context, { ...inherited, textHidden: true }, out)
+ return
+ }
+
+ const role = roleOf(element, context)
+ const name = computeName(element, context)
+ const descend = hidden !== 'contents' && !isLeaf(element, role)
+ if (!producesNode(role, name.text)) {
+ // The element is rendered, so its own visibility is visible even under
+ // an invisible ancestor: its text shows.
+ if (descend) {
+ collectChildren(element, context, { ...inherited, textHidden: false }, out)
+ }
+ return
+ }
+ const node = elementNode(element, role, name.text, context, above.disabled)
+ if (descend) {
+ collectChildren(
+ element,
+ context,
+ { ...inherited, textHidden: false, textConsumed: inherited.textConsumed || name.source === 'content' },
+ node.children,
+ )
+ }
+ out.push(node)
+}
+
+/**
+ * Appends the nodes for `parent`'s children to `out`: its flat-tree children
+ * in render order, then the elements it owns through aria-owns. An element
+ * some owner in the walked subtree claims is skipped at its DOM position and
+ * placed under that owner instead (ADR 18205 §3).
+ */
+function collectChildren(parent: Element, context: SnapshotContext, inherited: Inherited, out: AccessibilityNode[]): void {
+ // Text directly inside a closed details is collapsed content like any
+ // element other than its summary.
+ const textCollapsed = isClosedDetails(parent)
+ const children = flatTreeChildren(parent)
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ if (child.nodeType === TEXT_NODE) {
+ if (!inherited.textHidden && !inherited.textConsumed && !textCollapsed) {
+ const text = normalizeText(child.nodeValue || '')
+ if (text !== '') {
+ out.push(textLeaf(text))
+ }
+ }
+ continue
+ }
+ if (child.nodeType === ELEMENT_NODE && !context.claimed.has(child as Element)) {
+ visitElement(child as Element, context, inherited, out)
+ }
+ }
+ const owned = context.owned.get(parent)
+ if (owned) {
+ for (const element of owned) {
+ // Hidden-ness still follows DOM ancestry: the walk never reaches the
+ // owned element's DOM ancestors on its way here, so they are checked
+ // now. So does aria-disabled, which an owned element does not share
+ // with its owner.
+ if (isHiddenByAncestor(element, context)) {
+ continue
+ }
+ visitElement(element, context, { ...inherited, disabled: isDisabledByAncestor(element) }, out)
+ }
+ }
+}
+
+// Whether placing `target` under `owner` would make the tree circular: the
+// target is, or contains, the owner or one of the owner's accessibility
+// ancestors, following the ownership recorded so far and DOM parentage
+// otherwise.
+function wouldCycle(target: Element, owner: Element, ownerOf: Map): boolean {
+ for (let current: Element | null = owner; current; current = ownerOf.get(current) || current.parentElement) {
+ if (target.contains(current)) {
+ return true
+ }
+ }
+ return false
+}
+
+// The elements with aria-owns under `root`, in the flat tree the walk itself
+// visits: an owner inside an open shadow root is found, and a light-DOM
+// owner no slot takes is not, since it is never rendered and must not take
+// a rendered element away from its place.
+function collectOwners(element: Element, out: Element[]): void {
+ if (element.hasAttribute('aria-owns')) {
+ out.push(element)
+ }
+ const children = flatTreeChildren(element)
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ if (child.nodeType === ELEMENT_NODE) {
+ collectOwners(child as Element, out)
+ }
+ }
+}
+
+/**
+ * Records every aria-owns relation whose owner is in the flat tree under
+ * `root`, so owned elements can be skipped where they sit and placed under
+ * their owner. An element is owned by the first owner in flat-tree order
+ * that can take it: a relation that would make the tree circular (owning
+ * yourself, an ancestor, or something that already owns you) is dropped, as
+ * is one whose owner has presentational children and so no children to own,
+ * and an id that resolves to nothing is ignored.
+ */
+function recordOwnership(root: Element, context: SnapshotContext): void {
+ const owners: Element[] = []
+ collectOwners(root, owners)
+ const ownerOf = new Map()
+ for (const owner of owners) {
+ if (isLeaf(owner, roleOf(owner, context))) {
+ continue
+ }
+ const scope = owner.getRootNode() as Document | ShadowRoot
+ const targets: Element[] = []
+ for (const id of (owner.getAttribute('aria-owns') || '').trim().split(/\s+/)) {
+ const target = id ? scope.getElementById(id) : null
+ if (target && !context.claimed.has(target) && !wouldCycle(target, owner, ownerOf)) {
+ targets.push(target)
+ context.claimed.add(target)
+ ownerOf.set(target, owner)
+ }
+ }
+ if (targets.length) {
+ context.owned.set(owner, targets)
+ }
+ }
+}
+
+/**
+ * The snapshot rooted at `root`. The root always produces a node, whatever
+ * its role or hidden state, so a subtree snapshot of a plain div is still
+ * deterministic; a root whose subtree is hidden, by itself or by an
+ * ancestor, has no children.
+ */
+export function buildTree(root: Element, context: SnapshotContext): AccessibilityNode {
+ recordOwnership(root, context)
+ context.placed.add(root)
+ const role = roleOf(root, context)
+ const name = computeName(root, context)
+ const disabledByAncestor = isDisabledByAncestor(root)
+ const node = elementNode(root, role, name.text, context, disabledByAncestor)
+ const hidden = hiddenState(root, context)
+ const descend =
+ hidden !== 'subtree' && hidden !== 'contents' && !isLeaf(root, role) && !isHiddenByAncestor(root, context)
+ if (descend) {
+ collectChildren(
+ root,
+ context,
+ {
+ textHidden: hidden === 'self',
+ textConsumed: name.source === 'content',
+ disabled: disabledByAncestor || setsAriaDisabled(root),
+ },
+ node.children,
+ )
+ }
+ return node
+}
diff --git a/javascript/atoms/typescript/core/accessibility/types.ts b/javascript/atoms/typescript/core/accessibility/types.ts
new file mode 100644
index 0000000000000..24b3faf2a5c6a
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/types.ts
@@ -0,0 +1,39 @@
+// 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.
+
+// The shape every binding exposes, from docs/decisions/18205-accessibility-tree-snapshot.md §2.
+// Adding an optional field or a `states` key is additive; removing or renaming one is a
+// breaking change that needs a new decision record.
+
+export type StateValue = boolean | number | 'mixed'
+
+export interface AccessibilityNode {
+ /** WAI-ARIA role token; "text" for text leaves, "iframe" for frame leaves. */
+ role: string
+ /** Computed accessible name; "" when there is none. */
+ name: string
+ /** Computed accessible description; "" when there is none. */
+ description: string
+ /** Current value, present only for textbox, combobox, searchbox and range roles. */
+ value?: string
+ /** States the role supports or the element sets; {} when there are none. */
+ states: Record
+ /** The backing element; absent for text leaves and synthesized nodes. */
+ element?: Element
+ /** [] for leaves. */
+ children: AccessibilityNode[]
+}
diff --git a/javascript/atoms/typescript/core/accessibility/value.ts b/javascript/atoms/typescript/core/accessibility/value.ts
new file mode 100644
index 0000000000000..c931802fa76d9
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/value.ts
@@ -0,0 +1,89 @@
+// 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.
+
+// Values of embedded controls, as accname step 2E reads them when a control
+// sits inside something whose name is being computed. The states slice
+// reuses this for the `value` field.
+
+const TEXT_ROLES: ReadonlySet = new Set(['textbox', 'searchbox'])
+const SELECTION_ROLES: ReadonlySet = new Set(['combobox', 'listbox'])
+const RANGE_ROLES: ReadonlySet = new Set(['meter', 'progressbar', 'scrollbar', 'slider', 'spinbutton'])
+
+export function isEmbeddedControl(role: string): boolean {
+ return TEXT_ROLES.has(role) || SELECTION_ROLES.has(role) || RANGE_ROLES.has(role)
+}
+
+function selectedOptionsText(select: HTMLSelectElement): string {
+ const texts: string[] = []
+ for (let i = 0; i < select.options.length; i++) {
+ const option = select.options[i]
+ if (option.selected) {
+ texts.push(option.label || option.text)
+ }
+ }
+ return texts.join(' ')
+}
+
+// What the user sees: innerText follows CSS (display: none, visibility) where
+// textContent would include hidden descendants.
+function renderedText(element: Element): string {
+ const text = (element as HTMLElement).innerText
+ return typeof text === 'string' ? text : element.textContent || ''
+}
+
+// A listbox built from ARIA: its selected options, in document order.
+function selectedAriaOptions(listbox: Element): string {
+ const selected = listbox.querySelectorAll('[aria-selected="true"]')
+ const texts: string[] = []
+ for (let i = 0; i < selected.length; i++) {
+ texts.push(renderedText(selected[i]))
+ }
+ return texts.join(' ')
+}
+
+/** The current value of an embedded control, or null when the role has none. */
+export function embeddedValue(element: Element, role: string): string | null {
+ if (RANGE_ROLES.has(role)) {
+ const text = element.getAttribute('aria-valuetext')
+ if (text !== null && text.trim() !== '') {
+ return text.trim()
+ }
+ const now = element.getAttribute('aria-valuenow')
+ if (now !== null) {
+ return now.trim()
+ }
+ return 'value' in element ? String((element as HTMLInputElement).value) : null
+ }
+ if (TEXT_ROLES.has(role) || SELECTION_ROLES.has(role)) {
+ if (element.localName === 'select') {
+ return selectedOptionsText(element as HTMLSelectElement)
+ }
+ if ('value' in element && typeof (element as HTMLInputElement).value === 'string') {
+ const value = (element as HTMLInputElement).value
+ // A password is masked one bullet per character, as Chromium's
+ // accessibility tree exposes it: snapshots end up in logs and reports.
+ return element.localName === 'input' && (element as HTMLInputElement).type === 'password'
+ ? '\u2022'.repeat(value.length)
+ : value
+ }
+ if (role === 'listbox') {
+ return selectedAriaOptions(element)
+ }
+ return renderedText(element)
+ }
+ return null
+}
diff --git a/javascript/atoms/typescript/core/accessibility/wire.ts b/javascript/atoms/typescript/core/accessibility/wire.ts
new file mode 100644
index 0000000000000..f3e6e5da09a57
--- /dev/null
+++ b/javascript/atoms/typescript/core/accessibility/wire.ts
@@ -0,0 +1,119 @@
+// 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.
+
+import { isElement } from '../element.js'
+import accessibilitySnapshot from './snapshot.js'
+import type { AccessibilityNode, StateValue } from './types.js'
+
+/**
+ * A node as the language bindings receive it: the same fields as
+ * `AccessibilityNode`, with every reference replaced by an index into the
+ * arrays that travel beside the JSON text.
+ */
+export interface WireNode {
+ role: string
+ name: string
+ description: string
+ value?: string
+ states: Record
+ /** Index into `WireSnapshot.elements`; absent for text leaves. */
+ element?: number
+ /** Index into `WireSnapshot.windows`, on a frame node whose frame has a content window. */
+ frame?: number
+ children: WireNode[]
+}
+
+/**
+ * The shape the bindings fetch over WebDriver BiDi. The tree travels as one
+ * JSON string, so a binding parses it with its own JSON library instead of
+ * decoding a nested remote value; the elements and frame windows travel as
+ * plain arrays, which BiDi serializes as node references and browsing context
+ * ids in one pass.
+ */
+export interface WireSnapshot {
+ json: string
+ /** Every element the tree references, in order of first mention. */
+ elements: Element[]
+ /** The content window of every frame node, in order of first mention. */
+ windows: Window[]
+}
+
+/** The root is an element of this document that is no longer attached to it. */
+export interface WireError {
+ error: 'detached'
+}
+
+interface Encoded {
+ node: WireNode
+ elements: Element[]
+ windows: Window[]
+}
+
+/** Replaces the references in a tree with indices; pure, so it is unit tested without a DOM. */
+export function encodeWire(tree: AccessibilityNode): Encoded {
+ const elements: Element[] = []
+ const windows: Window[] = []
+
+ function encode(node: AccessibilityNode): WireNode {
+ const out: WireNode = { role: node.role, name: node.name, description: node.description, states: node.states, children: [] }
+ if (node.value !== undefined) {
+ out.value = node.value
+ }
+ if (node.element) {
+ out.element = elements.push(node.element) - 1
+ const window = node.role === 'iframe' ? (node.element as HTMLIFrameElement).contentWindow : null
+ if (window) {
+ out.frame = windows.push(window) - 1
+ }
+ }
+ out.children = node.children.map(encode)
+ return out
+ }
+
+ return { node: encode(tree), elements, windows }
+}
+
+/**
+ * The snapshot of this document (no `root`), or of the subtree under `root`,
+ * in the wire shape. Answers `null` for a root this document does not own,
+ * so a binding tries each document until one answers: Firefox hands a shared
+ * id of another document's node to the called function as `null`, and may
+ * resolve one to the node itself in any document of the browsing context
+ * group, which the ownership check catches. Answers `{ error: 'detached' }`
+ * for a root no longer in its document, so a binding never has to read an
+ * exception's text.
+ */
+function accessibilitySnapshotWire(root?: Element | null): WireSnapshot | WireError | null {
+ if (root === null) {
+ return null
+ }
+ if (root !== undefined) {
+ if (!isElement(root)) {
+ throw new Error('accessibilitySnapshotWire: root must be an Element')
+ }
+ if (root.ownerDocument !== document) {
+ return null
+ }
+ if (!root.isConnected) {
+ return { error: 'detached' }
+ }
+ }
+ const { node, elements, windows } = encodeWire(accessibilitySnapshot(root))
+ return { json: JSON.stringify(node), elements, windows }
+}
+
+export default accessibilitySnapshotWire
diff --git a/javascript/atoms/typescript/defs.bzl b/javascript/atoms/typescript/defs.bzl
index a4ec939acc7e6..aea343654ef12 100644
--- a/javascript/atoms/typescript/defs.bzl
+++ b/javascript/atoms/typescript/defs.bzl
@@ -302,6 +302,24 @@ ATOMS = [
"global": "bot.action.typescript.submit",
"aliases": [],
},
+ # Not a DOM primitive behind a WebDriver command: the snapshot is computed by
+ # script and labelled as such, so it gets its own namespace rather than
+ # bot.dom (docs/decisions/18205-accessibility-tree-snapshot.md).
+ {
+ "name": "accessibility-snapshot",
+ "export": "accessibilitySnapshot",
+ "global": "bot.accessibility.typescript.snapshot",
+ "aliases": [],
+ },
+ # The shape the language bindings fetch over BiDi: the same snapshot as JSON
+ # text beside the element and frame references, so no binding decodes a
+ # nested remote value (docs/decisions/18205-accessibility-tree-snapshot.md).
+ {
+ "name": "accessibility-snapshot-wire",
+ "export": "accessibilitySnapshotWire",
+ "global": "bot.accessibility.typescript.snapshotWire",
+ "aliases": [],
+ },
]
def typescript_atom(name, global_name, srcs, visibility = None):
diff --git a/javascript/atoms/typescript/testing/accessibility-text.ts b/javascript/atoms/typescript/testing/accessibility-text.ts
new file mode 100644
index 0000000000000..7d61c08785568
--- /dev/null
+++ b/javascript/atoms/typescript/testing/accessibility-text.ts
@@ -0,0 +1,78 @@
+// 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.
+
+import type { AccessibilityNode, StateValue } from '../core/accessibility/types.js'
+
+// The reference text rendering of a snapshot: the line grammar ADR 18205 §1
+// fixes for every binding's toText, kept here so the corpus's expected text
+// has one source and the bindings' ports are held to it. Test-only; the
+// bindings each ship their own renderer.
+//
+// - role "name" [state] [state=value]: value
+// - text: content
+//
+// One line per node, children indented two spaces. A true state is bare,
+// a false one is left out, any other value is spelled. A value is written as
+// is, except that a line break or a backslash in it is escaped (\n, \r, \\)
+// so a node never spans two lines. The description is not part of the text
+// form.
+
+const STATE_ORDER: ReadonlyArray = ['checked', 'pressed', 'expanded', 'selected', 'level', 'disabled', 'focused']
+
+function quote(name: string): string {
+ return '"' + name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
+}
+
+function oneLine(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/\r/g, '\\r').replace(/\n/g, '\\n')
+}
+
+function states(map: Record): string {
+ let out = ''
+ for (const key of STATE_ORDER) {
+ const value = map[key]
+ if (value === true) {
+ out += ` [${key}]`
+ } else if (value !== undefined && value !== false) {
+ out += ` [${key}=${value}]`
+ }
+ }
+ return out
+}
+
+function line(node: AccessibilityNode): string {
+ if (node.role === 'text') {
+ return `- text: ${node.name}`
+ }
+ let out = `- ${node.role}`
+ if (node.name !== '') {
+ out += ` ${quote(node.name)}`
+ }
+ out += states(node.states)
+ if (node.value !== undefined && node.value !== '') {
+ out += `: ${oneLine(node.value)}`
+ }
+ return out
+}
+
+export function toText(node: AccessibilityNode, indent = ''): string {
+ const lines = [indent + line(node)]
+ for (const child of node.children) {
+ lines.push(toText(child, indent + ' '))
+ }
+ return lines.join('\n')
+}
diff --git a/javascript/atoms/typescript/testing/accessibility-unit-tests.mjs b/javascript/atoms/typescript/testing/accessibility-unit-tests.mjs
new file mode 100644
index 0000000000000..3068b46276e21
--- /dev/null
+++ b/javascript/atoms/typescript/testing/accessibility-unit-tests.mjs
@@ -0,0 +1,147 @@
+// 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.
+
+// Unit tests for the pure parts of the accessibility modules, run by Node
+// with no DOM. The code under test is the test-only bundle
+// (typescript/testing/accessibility.generated.js), the same bytes the QUnit
+// pages load, evaluated the way the bindings evaluate an atom.
+//
+// It also holds the atom's own size to a ceiling: the bundle crosses the
+// wire on every injection into a document that predates the preload, so a
+// dependency that balloons it should fail here rather than ship.
+//
+// Usage: accessibility-unit-tests.mjs
+
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import { test } from 'node:test'
+import vm from 'node:vm'
+
+const [bundlePath, atomPath, wirePath] = process.argv.slice(2)
+if (!bundlePath || !atomPath || !wirePath) {
+ throw new Error('Expected the paths of the testing bundle, the atom bundle and the wire bundle')
+}
+const sandbox = { module: { exports: undefined } }
+vm.runInNewContext(`module.exports = ${fs.readFileSync(bundlePath, 'utf8').trim()};`, sandbox, { filename: bundlePath })
+const { encodeWire, toText } = sandbox.module.exports
+
+const node = (role, name, extra = {}) => ({ role, name, description: '', states: {}, children: [], ...extra })
+const text = (content) => node('text', content)
+
+test('a node is one line: role, quoted name, states, value', () => {
+ assert.equal(toText(node('button', 'Save')), '- button "Save"')
+ assert.equal(toText(node('list', '')), '- list')
+ assert.equal(toText(node('checkbox', 'Agree', { states: { checked: true } })), '- checkbox "Agree" [checked]')
+ assert.equal(toText(node('textbox', 'Email', { value: 'a@b.c' })), '- textbox "Email": a@b.c')
+ assert.equal(toText(node('textbox', 'Email', { states: { disabled: true }, value: 'x' })), '- textbox "Email" [disabled]: x')
+})
+
+test('states keep a fixed order; true is bare, false is left out, other values are spelled', () => {
+ const states = { focused: true, disabled: true, level: 2, selected: false, expanded: true, pressed: 'mixed', checked: false }
+ assert.equal(toText(node('heading', 'H', { states })), '- heading "H" [pressed=mixed] [expanded] [level=2] [disabled] [focused]')
+})
+
+test('text leaves are written as text lines', () => {
+ assert.equal(toText(text('plain words')), '- text: plain words')
+})
+
+test('children indent two spaces per level', () => {
+ const tree = node('list', '', {
+ children: [node('listitem', '', { children: [text('one'), node('link', 'more')] }), node('listitem', '', { children: [text('two')] })],
+ })
+ assert.equal(toText(tree), ['- list', ' - listitem', ' - text: one', ' - link "more"', ' - listitem', ' - text: two'].join('\n'))
+})
+
+test('quotes and backslashes in names are escaped; an empty value is not written', () => {
+ assert.equal(toText(node('button', 'Say "hi" \\ bye')), '- button "Say \\"hi\\" \\\\ bye"')
+ assert.equal(toText(node('textbox', 'Empty', { value: '' })), '- textbox "Empty"')
+})
+
+test('a value stays on one line: line breaks and backslashes are escaped', () => {
+ assert.equal(toText(node('textbox', 'Notes', { value: 'line one\nline two' })), '- textbox "Notes": line one\\nline two')
+ assert.equal(toText(node('textbox', 'Log', { value: 'a\r\nb' })), '- textbox "Log": a\\r\\nb')
+ assert.equal(toText(node('textbox', 'Path', { value: 'C:\\dir' })), '- textbox "Path": C:\\\\dir')
+})
+
+test('description is not part of the text form', () => {
+ assert.equal(toText({ ...node('button', 'Save'), description: 'Saves the form' }), '- button "Save"')
+})
+
+test('the atom bundle stays under 64 KiB', () => {
+ const size = fs.statSync(atomPath).size
+ assert.ok(size < 64 * 1024, `accessibility-snapshot.generated.js is ${size} bytes`)
+ // Printed so the size is in the log of every run, not only when it fails.
+ console.log(`# accessibility-snapshot.generated.js: ${size} bytes`)
+})
+
+test('the wire bundle, which the bindings send with every call, stays under 64 KiB', () => {
+ const size = fs.statSync(wirePath).size
+ assert.ok(size < 64 * 1024, `accessibility-snapshot-wire.generated.js is ${size} bytes`)
+ console.log(`# accessibility-snapshot-wire.generated.js: ${size} bytes`)
+})
+
+// The wire encoder is pure: elements and windows are whatever objects the tree
+// holds, so plain objects stand in for them here.
+test('the wire form replaces element references with indices, in first-mention order', () => {
+ const body = { id: 'body' }
+ const link = { id: 'link' }
+ const tree = node('generic', '', {
+ element: body,
+ children: [text('intro'), node('link', 'More', { element: link, children: [text('More')] })],
+ })
+
+ const wire = encodeWire(tree)
+
+ // The encoder runs in its own vm realm, so arrays are compared by content, not prototype.
+ assert.strictEqual(wire.elements.length, 2)
+ assert.strictEqual(wire.elements[0], body)
+ assert.strictEqual(wire.elements[1], link)
+ assert.strictEqual(wire.windows.length, 0)
+ const parsed = JSON.parse(JSON.stringify(wire.node))
+ assert.equal(parsed.element, 0)
+ assert.equal(parsed.children[0].element, undefined, 'a text leaf has no element')
+ assert.equal(parsed.children[1].element, 1)
+ assert.deepEqual(parsed.children[1].children[0], { role: 'text', name: 'More', description: '', states: {}, children: [] })
+})
+
+test('a frame node points at its content window; a frame without one has no frame index', () => {
+ const win = { name: 'child' }
+ const framed = { id: 'frame', contentWindow: win }
+ const empty = { id: 'frame2', contentWindow: null }
+ const tree = node('document', 'Frames', {
+ element: { id: 'root' },
+ children: [node('iframe', 'Embedded', { element: framed }), node('iframe', '', { element: empty })],
+ })
+
+ const wire = encodeWire(tree)
+
+ assert.strictEqual(wire.windows.length, 1)
+ assert.strictEqual(wire.windows[0], win)
+ assert.equal(wire.node.children[0].frame, 0)
+ assert.equal(wire.node.children[1].frame, undefined)
+ assert.equal(wire.node.children[1].element, 2)
+})
+
+test('the wire node keeps value and states exactly, and drops an absent value', () => {
+ const tree = node('textbox', 'Email', { element: { id: 'e' }, value: 'a@b.c', states: { focused: true, level: 2, pressed: 'mixed' } })
+
+ const parsed = JSON.parse(JSON.stringify(encodeWire(tree).node))
+
+ assert.equal(parsed.value, 'a@b.c')
+ assert.deepEqual(parsed.states, { focused: true, level: 2, pressed: 'mixed' })
+ assert.equal('value' in JSON.parse(JSON.stringify(encodeWire(node('list', '')).node)), false)
+})
diff --git a/javascript/atoms/typescript/testing/accessibility.ts b/javascript/atoms/typescript/testing/accessibility.ts
new file mode 100644
index 0000000000000..6faa14b57bd5a
--- /dev/null
+++ b/javascript/atoms/typescript/testing/accessibility.ts
@@ -0,0 +1,34 @@
+// 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.
+
+// Test-only entry point: the accessibility modules the public atom reaches
+// only through the tree walk, exposed so their QUnit pages can drive them
+// directly. Not in the ATOMS registry and never published.
+import { createContext } from '../core/accessibility/context.js'
+import { hiddenState, isCollapsedDetailsContent, isHidden } from '../core/accessibility/hidden.js'
+import { encodeWire } from '../core/accessibility/wire.js'
+import { toText } from './accessibility-text.js'
+
+// The hidden page asks about one element at a time, so each call gets the
+// fresh per-call context a snapshot would have.
+export default {
+ encodeWire,
+ hiddenState: (element: Element) => hiddenState(element, createContext()),
+ isCollapsedDetailsContent,
+ isHidden: (element: Element) => isHidden(element, createContext()),
+ toText,
+}
diff --git a/javascript/atoms/typescript/testing/corpus-consistent.mjs b/javascript/atoms/typescript/testing/corpus-consistent.mjs
new file mode 100644
index 0000000000000..fab021d05e236
--- /dev/null
+++ b/javascript/atoms/typescript/testing/corpus-consistent.mjs
@@ -0,0 +1,186 @@
+// 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.
+
+// Checks that the shared accessibility corpus in common/src/web/accessibility
+// is self-consistent, with no browser involved:
+//
+// - every page in index.json has an .expected.json and an .expected.txt,
+// and no expectation file is left over for a page the index does not list
+// - every expectation (engine-specific ones included) validates against
+// typescript/accessibility-node.schema.json, which is what turns the
+// ADR's compatibility rule into a failing test: a removed or renamed
+// field, or a state of the wrong type, fails here before any binding
+// sees it
+// - every .txt is exactly the reference renderer's output over its .json,
+// so the text form has one source
+// - every element id in a tree is unique within that tree and the root is
+// the html element, id "root"
+//
+// The validator below covers the subset of JSON Schema the schema file uses.
+//
+// Usage: corpus-consistent.mjs
+
+import fs from 'node:fs'
+import path from 'node:path'
+import vm from 'node:vm'
+
+const [corpusDir, schemaPath, bundlePath] = process.argv.slice(2)
+if (!corpusDir || !schemaPath || !bundlePath) {
+ throw new Error('Expected the corpus directory, the schema path and the testing bundle path')
+}
+
+const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
+const sandbox = { module: { exports: undefined } }
+vm.runInNewContext(`module.exports = ${fs.readFileSync(bundlePath, 'utf8').trim()};`, sandbox, { filename: bundlePath })
+const { toText } = sandbox.module.exports
+
+function resolve(ref) {
+ if (!ref.startsWith('#/')) {
+ throw new Error(`Only local $refs are supported: ${ref}`)
+ }
+ return ref
+ .slice(2)
+ .split('/')
+ .reduce((node, key) => node[key], schema)
+}
+
+function typeOf(value) {
+ if (Array.isArray(value)) return 'array'
+ if (value === null) return 'null'
+ if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number'
+ return typeof value
+}
+
+function validate(value, rule, at, errors) {
+ if (rule.$ref) {
+ return validate(value, resolve(rule.$ref), at, errors)
+ }
+ if (rule.oneOf) {
+ const matching = rule.oneOf.filter((option) => validate(value, option, at, []))
+ if (matching.length !== 1) {
+ errors.push(`${at}: ${JSON.stringify(value)} matches ${matching.length} of ${rule.oneOf.length} alternatives`)
+ }
+ return matching.length === 1
+ }
+ if ('const' in rule && value !== rule.const) {
+ errors.push(`${at}: expected ${JSON.stringify(rule.const)}, got ${JSON.stringify(value)}`)
+ return false
+ }
+ if (rule.type) {
+ const actual = typeOf(value)
+ if (actual !== rule.type && !(rule.type === 'number' && actual === 'integer')) {
+ errors.push(`${at}: expected ${rule.type}, got ${actual}`)
+ return false
+ }
+ }
+ let ok = true
+ if (rule.minLength !== undefined && value.length < rule.minLength) {
+ errors.push(`${at}: shorter than ${rule.minLength}`)
+ ok = false
+ }
+ if (rule.minimum !== undefined && value < rule.minimum) {
+ errors.push(`${at}: below ${rule.minimum}`)
+ ok = false
+ }
+ if (rule.type === 'array' && rule.items) {
+ value.forEach((item, i) => {
+ ok = validate(item, rule.items, `${at}[${i}]`, errors) && ok
+ })
+ }
+ if (rule.type === 'object') {
+ for (const key of rule.required || []) {
+ if (!(key in value)) {
+ errors.push(`${at}: missing ${key}`)
+ ok = false
+ }
+ }
+ for (const [key, child] of Object.entries(value)) {
+ const property = rule.properties && rule.properties[key]
+ if (property) {
+ ok = validate(child, property, `${at}.${key}`, errors) && ok
+ } else if (rule.additionalProperties === false) {
+ errors.push(`${at}: unexpected key ${key}`)
+ ok = false
+ } else if (rule.additionalProperties) {
+ ok = validate(child, rule.additionalProperties, `${at}.${key}`, errors) && ok
+ }
+ }
+ }
+ return ok
+}
+
+function checkIds(node, seen, errors, at = 'root') {
+ if (node.element !== undefined) {
+ if (seen.has(node.element)) {
+ errors.push(`${at}: element id "${node.element}" appears twice`)
+ }
+ seen.add(node.element)
+ }
+ node.children.forEach((child, i) => checkIds(child, seen, errors, `${at}.children[${i}]`))
+}
+
+const failures = []
+const index = JSON.parse(fs.readFileSync(path.join(corpusDir, 'index.json'), 'utf8'))
+const files = new Set(fs.readdirSync(corpusDir))
+const covered = new Set(['index.json', 'README.md'])
+
+for (const name of index) {
+ for (const required of [`${name}.html`, `${name}.expected.json`, `${name}.expected.txt`]) {
+ covered.add(required)
+ if (!files.has(required)) {
+ failures.push(`${name}: missing ${required}`)
+ }
+ }
+ const variants = [...files].filter((file) => file.startsWith(`${name}.expected.`) && file.endsWith('.json'))
+ for (const jsonFile of variants) {
+ covered.add(jsonFile)
+ const txtFile = jsonFile.replace(/\.json$/, '.txt')
+ covered.add(txtFile)
+ let tree
+ try {
+ tree = JSON.parse(fs.readFileSync(path.join(corpusDir, jsonFile), 'utf8'))
+ } catch (e) {
+ failures.push(`${jsonFile}: ${e.message}`)
+ continue
+ }
+ const errors = []
+ validate(tree, schema, 'root', errors)
+ if (tree.role !== 'document' || tree.element !== 'root') {
+ errors.push('root: expected the html element (role document, id "root")')
+ }
+ checkIds(tree, new Set(), errors)
+ for (const error of errors) {
+ failures.push(`${jsonFile}: ${error}`)
+ }
+ if (!files.has(txtFile)) {
+ failures.push(`${jsonFile}: no matching ${txtFile}`)
+ } else if (fs.readFileSync(path.join(corpusDir, txtFile), 'utf8') !== toText(tree) + '\n') {
+ failures.push(`${txtFile}: not the renderer's output over ${jsonFile}; re-record it`)
+ }
+ }
+}
+for (const file of files) {
+ if (!covered.has(file) && /\.expected\./.test(file)) {
+ failures.push(`${file}: not listed in index.json`)
+ }
+}
+
+if (failures.length) {
+ console.error(`Corpus is inconsistent:\n ${failures.join('\n ')}`)
+ process.exit(1)
+}
+console.log(`Corpus consistent: ${index.length} pages, every expectation valid against the schema and its text up to date.`)
diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel
index a3e67d5ab2038..4ba722e775034 100644
--- a/javascript/selenium-webdriver/BUILD.bazel
+++ b/javascript/selenium-webdriver/BUILD.bazel
@@ -172,6 +172,7 @@ npm_package(
":manager-sbom",
":manager-windows",
":prod-src-files",
+ "//javascript/selenium-webdriver/lib/atoms:accessibility_snapshot",
"//javascript/selenium-webdriver/lib/atoms:bidi-mutation-listener",
"//javascript/selenium-webdriver/lib/atoms:find-elements",
"//javascript/selenium-webdriver/lib/atoms:get_attribute",
@@ -218,6 +219,7 @@ SMALL_TESTS = [
"test/http/util_test.js",
"test/io/io_test.js",
"test/io/zip_test.js",
+ "test/lib/accessibility_snapshot_test.js",
"test/lib/bidi_connection_test.js",
"test/lib/by_test.js",
"test/lib/credentials_test.js",
@@ -493,6 +495,8 @@ eslint_bin.eslint_test(
":node_modules/ws",
":package-json",
":prod-src-files",
+ # lib/accessibility_snapshot.js requires the built atom module statically.
+ "//javascript/selenium-webdriver/lib/atoms:accessibility_snapshot",
],
tags = [
"lint",
diff --git a/javascript/selenium-webdriver/eslint.config.js b/javascript/selenium-webdriver/eslint.config.js
index ea68f2c2d0e31..82a6ef513b8db 100644
--- a/javascript/selenium-webdriver/eslint.config.js
+++ b/javascript/selenium-webdriver/eslint.config.js
@@ -25,7 +25,8 @@ const nodePlugin = require('eslint-plugin-n')
module.exports = [
// Generated by the BiDi generator, which owns its style.
- { ignores: ['bidi/generated/'] },
+ // Generated: the BiDi modules, and the atom module the lint target carries so lib/accessibility_snapshot.js resolves.
+ { ignores: ['bidi/generated/', 'lib/atoms/accessibility-snapshot.js'] },
js.configs.recommended,
eslintPluginPrettierRecommended,
mochaPlugin.configs.recommended,
diff --git a/javascript/selenium-webdriver/index.js b/javascript/selenium-webdriver/index.js
index 363a81d691204..c30cd658f6943 100644
--- a/javascript/selenium-webdriver/index.js
+++ b/javascript/selenium-webdriver/index.js
@@ -806,6 +806,7 @@ exports.Session = session.Session
exports.ThenableWebDriver = ThenableWebDriver
exports.WebDriver = webdriver.WebDriver
exports.WebElement = webdriver.WebElement
+exports.AccessibilityNode = webdriver.AccessibilityNode
exports.WebElementCondition = webdriver.WebElementCondition
exports.WebElementPromise = webdriver.WebElementPromise
exports.WebExtension = webExtension.WebExtension
diff --git a/javascript/selenium-webdriver/lib/accessibility_snapshot.js b/javascript/selenium-webdriver/lib/accessibility_snapshot.js
new file mode 100644
index 0000000000000..f59e94e2df47f
--- /dev/null
+++ b/javascript/selenium-webdriver/lib/accessibility_snapshot.js
@@ -0,0 +1,395 @@
+// 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.
+
+'use strict'
+
+/**
+ * @fileoverview Accessibility snapshots computed in the page (ADR 18205).
+ *
+ * The tree is computed by the `accessibility-snapshot` atom of `@selenium/atoms`,
+ * run over WebDriver BiDi in a sandbox realm so page scripts cannot interfere
+ * with it. The atom's wire entry hands the tree back as one JSON string beside
+ * the element and frame references, so this module holds only what is the
+ * binding's: the public node type and its text form, the mapping of references
+ * to WebElement, and frame stitching across browsing contexts.
+ *
+ * The output is *computed* from the DOM following WAI-ARIA, html-aam and
+ * accname; it is not the browser's own accessibility tree and can differ from
+ * it at the edges.
+ */
+
+const path = require('node:path')
+const { getBidiConnection } = require('./bidi_connection')
+const error = require('./error')
+const logging = require('./logging')
+
+const log_ = logging.getLogger(`${logging.Type.DRIVER}.accessibility`)
+
+/** The order the text form writes states in (ADR 18205 §1). */
+const STATE_ORDER = ['checked', 'pressed', 'expanded', 'selected', 'level', 'disabled', 'focused']
+
+/** The sandbox realm the atom runs in: page scripts cannot reach its prototypes. */
+const SANDBOX = 'selenium'
+const SERIALIZATION_OPTIONS = { maxDomDepth: 0 }
+const STALE_MESSAGE = 'the snapshot root is no longer attached to a document'
+
+/**
+ * One node of an accessibility snapshot.
+ */
+class AccessibilityNode {
+ /**
+ * @param {{role: string, name: string, description: (string|undefined),
+ * value: (string|undefined), states: (!Object|undefined),
+ * element: (?WebElement|undefined), children: (!Array|undefined)}} fields
+ */
+ constructor({ role, name, description = '', value = undefined, states = {}, element = null, children = [] }) {
+ /** The WAI-ARIA role token; `"text"` for text leaves and `"iframe"` for frames, under which the frame's own tree is attached. @type {string} */
+ this.role = role
+ /** The computed accessible name, `""` when there is none. @type {string} */
+ this.name = name
+ /** The computed accessible description, `""` when there is none. @type {string} */
+ this.description = description
+ /**
+ * The current value for textbox, searchbox, combobox and range roles;
+ * `undefined` where the role has none. A password field's value is masked.
+ * @type {(string|undefined)}
+ */
+ this.value = value
+ /**
+ * `checked`, `pressed`, `expanded`, `selected`, `level`, `disabled` and
+ * `focused`, each present only when it applies; new keys may be added in any
+ * release, so ignore keys you do not recognise.
+ * @type {!Object}
+ */
+ this.states = states
+ /**
+ * The element the node is backed by, usable for further commands, or `null`
+ * for text leaves. An element inside a child frame can be used through
+ * classic commands only once the driver has switched to that frame.
+ * @type {?WebElement}
+ */
+ this.element = element
+ /** The child nodes, `[]` for leaves. @type {!Array} */
+ this.children = children
+ }
+
+ /**
+ * Renders the tree as text, one node per line.
+ *
+ * The line grammar is `- role "name" [state] [state=value]: value`, children
+ * indented two spaces, text leaves written as `- text: content`. An empty
+ * name is left out, as is an absent or empty value; a `true` state is written
+ * bare, a `false` one left out, any other value spelled. A line break or
+ * backslash in a value is escaped so a node never spans two lines. The
+ * description and element references are not rendered.
+ *
+ * @return {string} The text form, without a trailing newline.
+ */
+ toText() {
+ return [...lines(this, '')].join('\n')
+ }
+}
+
+function* lines(node, indent) {
+ yield indent + lineFor(node)
+ for (const child of node.children) {
+ yield* lines(child, indent + ' ')
+ }
+}
+
+function lineFor(node) {
+ if (node.role === 'text') {
+ return `- text: ${node.name}`
+ }
+ let out = `- ${node.role}`
+ if (node.name !== '') {
+ out += ` ${quote(node.name)}`
+ }
+ for (const key of STATE_ORDER) {
+ if (!(key in node.states)) continue
+ const state = node.states[key]
+ if (state === true) {
+ out += ` [${key}]`
+ } else if (state !== false) {
+ out += ` [${key}=${state}]`
+ }
+ }
+ if (node.value) {
+ out += `: ${oneLine(node.value)}`
+ }
+ return out
+}
+
+function quote(name) {
+ return '"' + name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
+}
+
+function oneLine(value) {
+ return value.replace(/\\/g, '\\\\').replace(/\r/g, '\\r').replace(/\n/g, '\\n')
+}
+
+/**
+ * Builds the node tree from the atom's wire form.
+ *
+ * @param {!Object} data The parsed JSON of the tree: the node's fields, with
+ * `element` an index into `elements` and, on a frame node, `frame` an index
+ * the binding resolves to the frame's own tree.
+ * @param {!Array} elements The elements the tree references, in the atom's order.
+ * @param {function(number): !Promise>} frameChildren The
+ * children of a frame node, from its `frame` index: the frame's document, or
+ * nothing when it cannot be reached.
+ * @return {!Promise} The node, with its subtree.
+ */
+async function nodeFromWire(data, elements, frameChildren) {
+ const children = []
+ if ('frame' in data) {
+ children.push(...(await frameChildren(data.frame)))
+ } else {
+ for (const child of data.children) {
+ children.push(await nodeFromWire(child, elements, frameChildren))
+ }
+ }
+ return new AccessibilityNode({
+ role: data.role,
+ name: data.name,
+ description: data.description ?? '',
+ value: data.value,
+ states: { ...data.states },
+ element: data.element === undefined ? null : elements[data.element],
+ children,
+ })
+}
+
+let atomModule = null
+
+/**
+ * The atom's wire entry as a function declaration: the module built by
+ * lib/atoms/make-atoms-module.js wraps the bundle in a function whose source
+ * `String()` returns, which `script.callFunction` can take as is.
+ * @return {string}
+ */
+function atomDeclaration() {
+ if (atomModule === null) {
+ try {
+ atomModule = require('./atoms/accessibility-snapshot.js')
+ } catch {
+ try {
+ atomModule = require(
+ path.resolve('../../../bazel-bin/javascript/selenium-webdriver/lib/atoms/accessibility-snapshot.js'),
+ )
+ } catch (ex) {
+ log_.severe(ex)
+ throw new Error(
+ 'Failed to import atoms module accessibility-snapshot.js. If running in dev mode, you need to run' +
+ ' `bazel build //javascript/selenium-webdriver/lib/atoms:accessibility_snapshot` from the project' +
+ ' root first.',
+ { cause: ex },
+ )
+ }
+ }
+ }
+ return String(atomModule)
+}
+
+/** A shared id that the targeted document does not own. */
+class NoSuchNode extends Error {}
+
+/** Whether an error says a frame or its document is gone, rather than that a command failed. */
+function frameUnreachable(e) {
+ const message = String(e?.message ?? e)
+ return e instanceof NoSuchNode || message.includes('no such frame') || message.includes('no such node')
+}
+
+/** The entries of an `object` remote value by property name. */
+function objectFields(value) {
+ if (!value || value.type !== 'object') {
+ throw new error.WebDriverError(`accessibility snapshot: expected an object remote value, got ${value?.type}`)
+ }
+ // A property name is written as a plain string, or as a string remote value.
+ return new Map((value.value ?? []).map(([key, entry]) => [typeof key === 'string' ? key : stringValue(key), entry]))
+}
+
+function stringValue(value) {
+ return String(value?.value ?? '')
+}
+
+function sharedIdOf(node) {
+ if (node?.type !== 'node' || !node.sharedId) {
+ throw new error.WebDriverError('accessibility snapshot: expected a node reference in the elements list')
+ }
+ return node.sharedId
+}
+
+/** The browsing context id of a `window` remote value; null for a frame with no reachable context. */
+function contextOf(window) {
+ return window?.type === 'window' ? (window.value?.context ?? null) : null
+}
+
+/**
+ * Takes snapshots for one driver: runs the atom in each document and stitches
+ * child frames into the tree.
+ */
+class AccessibilitySnapshotter {
+ /**
+ * @param {!WebDriver} driver The driver to snapshot through.
+ * @param {function(string): !WebElement} elementFactory Makes the element for a shared id.
+ */
+ constructor(driver, elementFactory) {
+ this.driver_ = driver
+ this.elementFactory_ = elementFactory
+ }
+
+ /**
+ * Snapshots the whole page, or the subtree under `root`.
+ *
+ * @param {?WebElement} root The element whose subtree to snapshot, or null for
+ * the page of the current window's top-level browsing context.
+ * @return {!Promise} The snapshot, with every reachable
+ * child frame's tree attached under its `iframe` node.
+ * @throws {error.UnsupportedOperationError} When the session has no BiDi connection.
+ * @throws {error.StaleElementReferenceError} When `root` no longer belongs to a document.
+ */
+ async snapshot(root) {
+ const caps = await this.driver_.getCapabilities()
+ if (!caps.get('webSocketUrl')) {
+ throw new error.UnsupportedOperationError(
+ 'getAccessibilitySnapshot() requires a WebDriver BiDi session; enable it with options.enableBidi()' +
+ ' before building the driver',
+ )
+ }
+ const top = await this.driver_.getWindowHandle()
+ if (!root) {
+ const node = await this.snapshotContext_(top, null)
+ if (node === null) {
+ throw new error.WebDriverError('accessibility snapshot: the script returned nothing')
+ }
+ return node
+ }
+ // The element may live in any frame of the window. Chromium resolves its shared
+ // id only in the document that owns it; Firefox resolves it in any document of
+ // the browsing context group, so the atom checks ownership itself and each
+ // context is tried until the owner is found.
+ const rootId = await root.getId()
+ for (const context of await this.contexts_(top)) {
+ let node
+ try {
+ node = await this.snapshotContext_(context, rootId)
+ } catch (e) {
+ if (!(e instanceof NoSuchNode)) throw e
+ continue
+ }
+ if (node !== null) return node
+ }
+ throw new error.StaleElementReferenceError(STALE_MESSAGE)
+ }
+
+ /**
+ * Sends one BiDi command over the driver's connection and returns its result.
+ * The call here returns node remote values by the hundred, whose attribute
+ * maps the generated domain modules would validate one by one.
+ * @private
+ */
+ async send_(method, params) {
+ const bidi = await getBidiConnection(this.driver_)
+ const response = await bidi.send({ method, params })
+ if (response.error) {
+ throw new Error(`${response.error}: ${response.message}`)
+ }
+ return response.result
+ }
+
+ /**
+ * The snapshot of `context`, or null when another document owns the root.
+ * @private
+ */
+ async snapshotContext_(context, rootId) {
+ const result = await this.call_(context, rootId ? [{ sharedId: rootId }] : [])
+ if (result.type === 'null') return null
+ const fields = objectFields(result)
+ if (fields.has('error')) {
+ // The only error the atom reports as data: the root left its document.
+ throw new error.StaleElementReferenceError(STALE_MESSAGE)
+ }
+ const elements = (fields.get('elements')?.value ?? []).map((node) => this.elementFactory_(sharedIdOf(node)))
+ const windows = (fields.get('windows')?.value ?? []).map(contextOf)
+ return nodeFromWire(JSON.parse(stringValue(fields.get('json'))), elements, (index) => this.stitch_(windows[index]))
+ }
+
+ /**
+ * Runs the atom's wire entry in the sandbox of `context` and returns the remote value of its result.
+ * @private
+ */
+ async call_(context, args) {
+ let raw
+ try {
+ raw = await this.send_('script.callFunction', {
+ functionDeclaration: `(root) => (${atomDeclaration()})(root)`,
+ awaitPromise: false,
+ target: { context, sandbox: SANDBOX },
+ arguments: args,
+ resultOwnership: 'none',
+ serializationOptions: SERIALIZATION_OPTIONS,
+ })
+ } catch (e) {
+ // The BiDi "no such node" error: the document of this context does not own
+ // the root's shared id, either because the root lives in another frame or
+ // because its document is gone.
+ if (String(e?.message).includes('no such node')) {
+ throw new NoSuchNode(e.message)
+ }
+ throw e
+ }
+ if (raw.type === 'exception') {
+ const text = String(raw.exceptionDetails?.text ?? raw.exceptionDetails)
+ throw new error.WebDriverError(`accessibility snapshot failed in the page: ${text}`)
+ }
+ return raw.result
+ }
+
+ /**
+ * The document of a frame as the frame node's one child, or no children when unreachable.
+ * @private
+ */
+ async stitch_(context) {
+ if (!context) return []
+ try {
+ const document = await this.snapshotContext_(context, null)
+ return document === null ? [] : [document]
+ } catch (e) {
+ // Gone between discovery and the call, or mid-navigation: the frame stays
+ // a leaf (ADR §1). Anything else is a real failure.
+ if (!frameUnreachable(e)) throw e
+ log_.finer(`accessibility snapshot: frame ${context} not reachable: ${e}`)
+ return []
+ }
+ }
+
+ /** The window's browsing contexts, top-level first, then each frame depth-first. */
+ async contexts_(top) {
+ const contexts = [top]
+ const tree = await this.send_('browsingContext.getTree', { root: top })
+ const stack = [...(tree.contexts ?? [])]
+ while (stack.length) {
+ const info = stack.shift()
+ if (info.context !== top) contexts.push(info.context)
+ stack.unshift(...(info.children ?? []))
+ }
+ return contexts
+ }
+}
+
+module.exports = { AccessibilityNode, AccessibilitySnapshotter, nodeFromWire }
diff --git a/javascript/selenium-webdriver/lib/atoms/BUILD.bazel b/javascript/selenium-webdriver/lib/atoms/BUILD.bazel
index 56515c19ada77..fdeaf653559c2 100644
--- a/javascript/selenium-webdriver/lib/atoms/BUILD.bazel
+++ b/javascript/selenium-webdriver/lib/atoms/BUILD.bazel
@@ -59,6 +59,18 @@ js_run_binary(
visibility = ["//javascript/selenium-webdriver:__pkg__"],
)
+js_run_binary(
+ name = "accessibility_snapshot",
+ srcs = ["//javascript/atoms/fragments:accessibility-snapshot-wire-typescript.js"],
+ outs = ["accessibility-snapshot.js"],
+ args = [
+ "$(rootpath //javascript/atoms/fragments:accessibility-snapshot-wire-typescript.js)",
+ "$(rootpath :accessibility-snapshot.js)",
+ ],
+ tool = ":make_atoms_module",
+ visibility = ["//javascript/selenium-webdriver:__pkg__"],
+)
+
copy_file(
name = "mutation-listener",
src = "//javascript/cdp-support:mutation-listener.js",
diff --git a/javascript/selenium-webdriver/lib/webdriver.js b/javascript/selenium-webdriver/lib/webdriver.js
index 8de31e52a7071..ad60f21913f84 100644
--- a/javascript/selenium-webdriver/lib/webdriver.js
+++ b/javascript/selenium-webdriver/lib/webdriver.js
@@ -44,6 +44,7 @@ const { getBidiConnection, closeBidiConnection } = require('./bidi_connection')
const { PinnedScript } = require('./pinnedScript')
const JSZip = require('jszip')
const Script = require('./script')
+const { AccessibilityNode, AccessibilitySnapshotter } = require('./accessibility_snapshot')
const Network = require('./network')
const Dialog = require('./fedcm/dialog')
const webExtension = require('./web_extension')
@@ -659,6 +660,7 @@ function filterNonW3CCaps(capabilities) {
*/
class WebDriver {
#script = undefined
+ #accessibility
#network = undefined
/**
* @param {!(./session.Session|IThenable)} session Either
@@ -1155,6 +1157,37 @@ class WebDriver {
return new TargetLocator(this)
}
+ /**
+ * Snapshots the accessibility tree of the page.
+ *
+ * The tree is computed in the page from the DOM, following WAI-ARIA, html-aam
+ * and accname; it is not the browser's own accessibility tree. It is rooted
+ * at the top-level browsing context of the current window, whatever frame the
+ * driver has switched to, and every child frame's tree is attached under its
+ * `iframe` node. To scope to a frame, take `getAccessibilitySnapshot()` of
+ * its `iframe` element. The snapshot is a copy taken at one point in time.
+ *
+ * const tree = await driver.getAccessibilitySnapshot()
+ * console.log(tree.toText())
+ * await tree.children[0].element.click()
+ *
+ * @return {!Promise} The root node, of role `document`,
+ * named from the title.
+ * @throws {error.UnsupportedOperationError} When the session has no WebDriver
+ * BiDi connection.
+ */
+ getAccessibilitySnapshot() {
+ return this.accessibility_().snapshot(null)
+ }
+
+ /** @private @return {!AccessibilitySnapshotter} */
+ accessibility_() {
+ if (this.#accessibility === undefined) {
+ this.#accessibility = new AccessibilitySnapshotter(this, (id) => new WebElement(this, id))
+ }
+ return this.#accessibility
+ }
+
script() {
// The Script calls the LogInspector which maintains state of the callbacks.
// Returning a new instance of the same driver will not work while removing callbacks.
@@ -2998,6 +3031,23 @@ class WebElement {
return this.execute_(new command.Command(command.Name.GET_ELEMENT_TEXT))
}
+ /**
+ * Snapshots the accessibility tree of this element's subtree.
+ *
+ * The root node is always this element, whatever its role; if the element is
+ * hidden, by itself or by an ancestor, that node has no children. Child frames
+ * are attached under their `iframe` nodes. See
+ * {@link WebDriver#getAccessibilitySnapshot}.
+ *
+ * @return {!Promise} The node for this element, with its subtree.
+ * @throws {error.UnsupportedOperationError} When the session has no WebDriver
+ * BiDi connection.
+ * @throws {error.StaleElementReferenceError} When the element is no longer attached.
+ */
+ getAccessibilitySnapshot() {
+ return this.driver_.accessibility_().snapshot(this)
+ }
+
/**
* Get the computed WAI-ARIA role of element.
*
@@ -3473,6 +3523,7 @@ class AlertPromise extends Alert {
// PUBLIC API
module.exports = {
+ AccessibilityNode,
Alert,
AlertPromise,
Condition,
diff --git a/javascript/selenium-webdriver/test/accessibility_snapshot_test.js b/javascript/selenium-webdriver/test/accessibility_snapshot_test.js
new file mode 100644
index 0000000000000..d4852473c2800
--- /dev/null
+++ b/javascript/selenium-webdriver/test/accessibility_snapshot_test.js
@@ -0,0 +1,239 @@
+// 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.
+
+'use strict'
+
+// driver.getAccessibilitySnapshot() and element.getAccessibilitySnapshot()
+// over BiDi (ADR 18205). 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.
+
+const assert = require('node:assert')
+const fs = require('node:fs')
+const path = require('node:path')
+const { Browser, By, error, WebElement } = require('selenium-webdriver')
+const { AccessibilityNode } = require('selenium-webdriver')
+const { suite, whereIs } = require('../lib/test')
+const { locate } = require('../lib/test/resources')
+
+const CORPUS = locate('common/src/web/accessibility')
+// The atom records frames as leaves; the binding fills them in, so this page has its own test.
+const FRAMES_PAGE = '014-iframe'
+
+function read(file) {
+ return fs.readFileSync(path.join(CORPUS, file), 'utf8')
+}
+
+const PAGES = JSON.parse(read('index.json')).filter((page) => page !== FRAMES_PAGE)
+
+function engineOf(browserName) {
+ const name = (browserName || '').toLowerCase()
+ if (name.includes('firefox')) return 'firefox'
+ if (name.includes('safari') || name.includes('webkit')) return 'webkit'
+ return 'chromium'
+}
+
+/** The engine-specific expectation when one is recorded, else the shared one. */
+function expected(engine, page, suffix) {
+ const specific = `${page}.expected.${engine}.${suffix}`
+ return read(fs.existsSync(path.join(CORPUS, specific)) ? specific : `${page}.expected.${suffix}`)
+}
+
+/** The corpus JSON form: every field written, `element` as the element's id. */
+async function serialize(node) {
+ const out = { role: node.role, name: node.name, description: node.description }
+ if (node.value !== undefined) out.value = node.value
+ out.states = node.states
+ if (node.element) out.element = await node.element.getDomAttribute('id')
+ out.children = []
+ for (const child of node.children) out.children.push(await serialize(child))
+ return out
+}
+
+function* walk(node) {
+ yield node
+ for (const child of node.children) yield* walk(child)
+}
+
+suite(
+ function (env) {
+ let driver
+
+ before(async function () {
+ driver = await env.builder().build()
+ })
+
+ after(function () {
+ return driver.quit()
+ })
+
+ describe('getAccessibilitySnapshot', function () {
+ for (const page of PAGES) {
+ it(`matches the corpus for ${page}`, async function () {
+ await driver.get(whereIs(`accessibility/${page}.html`))
+ const engine = engineOf((await driver.getCapabilities()).getBrowserName())
+
+ const tree = await driver.getAccessibilitySnapshot()
+
+ assert.deepStrictEqual(await serialize(tree), JSON.parse(expected(engine, page, 'json')))
+ assert.strictEqual(tree.toText() + '\n', expected(engine, page, 'txt'))
+ })
+ }
+
+ it('is rooted at the document', async function () {
+ await driver.get(whereIs('accessibility/001-headings-and-landmarks.html'))
+
+ const tree = await driver.getAccessibilitySnapshot()
+
+ assert.ok(tree instanceof AccessibilityNode)
+ assert.strictEqual(tree.role, 'document')
+ assert.ok(tree.element instanceof WebElement)
+ assert.strictEqual(await tree.element.getTagName(), 'html')
+ })
+
+ it('returns element references usable for further commands', async function () {
+ await driver.get(whereIs('accessibility/003-links-and-buttons.html'))
+
+ const tree = await driver.getAccessibilitySnapshot()
+
+ const button = [...walk(tree)].find((node) => node.role === 'button')
+ assert.ok(button, 'the page has buttons')
+ assert.ok(['button', 'input', 'a'].includes(await button.element.getTagName()))
+ assert.strictEqual(await button.element.getDomAttribute('id'), (await serialize(button)).element)
+ })
+
+ it('always produces a node for a subtree root', async function () {
+ await driver.get(whereIs('accessibility/005-lists.html'))
+ const body = await driver.findElement(By.css('body'))
+
+ const tree = await body.getAccessibilitySnapshot()
+
+ assert.strictEqual(tree.role, 'generic')
+ assert.strictEqual(await tree.element.getTagName(), 'body')
+ assert.ok([...walk(tree)].some((node) => node.role === 'list'))
+ })
+
+ it('snapshots the subtree of a node from an earlier snapshot', async function () {
+ await driver.get(whereIs('accessibility/005-lists.html'))
+ const firstList = [...walk(await driver.getAccessibilitySnapshot())].find((node) => node.role === 'list')
+
+ const tree = await firstList.element.getAccessibilitySnapshot()
+
+ assert.strictEqual(tree.role, 'list')
+ assert.deepStrictEqual(
+ tree.children.map((child) => child.role),
+ firstList.children.map((child) => child.role),
+ )
+ })
+
+ it('stitches frames in', async function () {
+ await driver.get(whereIs(`accessibility/${FRAMES_PAGE}.html`))
+
+ const tree = await driver.getAccessibilitySnapshot()
+
+ const frames = [...walk(tree)].filter((node) => node.role === 'iframe')
+ assert.deepStrictEqual(
+ frames.map((frame) => frame.name),
+ ['Embedded content', ''],
+ )
+ for (const frame of frames) {
+ assert.strictEqual(frame.children.length, 1, 'a reachable frame carries its document as the one child')
+ assert.strictEqual(frame.children[0].role, 'document')
+ assert.ok(frame.children[0].element instanceof WebElement)
+ }
+ // The text form renders the stitched tree; the atom-level expectation stops at the leaves.
+ assert.strictEqual(tree.toText().split('- document').length - 1, 3)
+ // A reference inside a frame works through classic commands once that frame is current.
+ await driver.switchTo().frame(frames[0].element)
+ try {
+ assert.strictEqual(await frames[0].children[0].element.getTagName(), 'html')
+ } finally {
+ await driver.switchTo().defaultContent()
+ }
+ })
+
+ it('carries the document of an iframe taken as the root', async function () {
+ await driver.get(whereIs(`accessibility/${FRAMES_PAGE}.html`))
+ const frame = await driver.findElement(By.id('frame'))
+
+ const tree = await frame.getAccessibilitySnapshot()
+
+ assert.strictEqual(tree.role, 'iframe')
+ assert.strictEqual(tree.name, 'Embedded content')
+ assert.deepStrictEqual(
+ tree.children.map((child) => child.role),
+ ['document'],
+ )
+ })
+
+ it('snapshots an element inside a frame', async function () {
+ await driver.get(whereIs(`accessibility/${FRAMES_PAGE}.html`))
+ await driver.switchTo().frame('frame')
+ try {
+ const body = await driver.findElement(By.css('body'))
+ const tree = await body.getAccessibilitySnapshot()
+ // The root is the frame's body, not the top document that owns the window.
+ assert.strictEqual(tree.role, 'generic')
+ assert.strictEqual(tree.name, '')
+ assert.strictEqual(await tree.element.getTagName(), 'body')
+ } finally {
+ await driver.switchTo().defaultContent()
+ }
+ })
+
+ it('snapshots the whole page whatever frame the driver switched to', async function () {
+ await driver.get(whereIs(`accessibility/${FRAMES_PAGE}.html`))
+ await driver.switchTo().frame('frame')
+ try {
+ const tree = await driver.getAccessibilitySnapshot()
+ assert.strictEqual(tree.name, 'Frames')
+ } finally {
+ await driver.switchTo().defaultContent()
+ }
+ })
+
+ it('raises the stale element error for a root from a previous document', async function () {
+ await driver.get(whereIs('accessibility/005-lists.html'))
+ const body = await driver.findElement(By.css('body'))
+ await driver.get(whereIs('accessibility/001-headings-and-landmarks.html'))
+
+ await assert.rejects(body.getAccessibilitySnapshot(), error.StaleElementReferenceError)
+ })
+
+ it('raises the stale element error for a detached root', async function () {
+ await driver.get(whereIs('accessibility/005-lists.html'))
+ const item = await driver.findElement(By.css('li'))
+ await driver.executeScript('arguments[0].remove()', item)
+
+ await assert.rejects(item.getAccessibilitySnapshot(), error.StaleElementReferenceError)
+ })
+
+ it('is a copy taken at one point in time', async function () {
+ await driver.get(whereIs('accessibility/005-lists.html'))
+ const before = await driver.getAccessibilitySnapshot()
+ await driver.executeScript("document.querySelector('li').textContent = 'changed'")
+
+ const after = await driver.getAccessibilitySnapshot()
+
+ assert.notStrictEqual(before.toText(), after.toText())
+ assert.ok(after.toText().includes('changed'))
+ })
+ })
+ },
+ // The snapshot needs BiDi, which the test environment enables for these browsers only.
+ { browsers: [Browser.CHROME, Browser.EDGE, Browser.FIREFOX] },
+)
diff --git a/javascript/selenium-webdriver/test/lib/accessibility_snapshot_test.js b/javascript/selenium-webdriver/test/lib/accessibility_snapshot_test.js
new file mode 100644
index 0000000000000..dbe266afefa5b
--- /dev/null
+++ b/javascript/selenium-webdriver/test/lib/accessibility_snapshot_test.js
@@ -0,0 +1,277 @@
+// 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.
+
+'use strict'
+
+// The pure parts of the accessibility snapshot (ADR 18205): the text renderer,
+// held to the shared corpus, the decoder from the BiDi wire shape, and the
+// guard for sessions without BiDi.
+
+const assert = require('node:assert')
+const fs = require('node:fs')
+const path = require('node:path')
+const { locate } = require('../../lib/test/resources')
+const {
+ AccessibilityNode,
+ AccessibilitySnapshotter,
+ nodeFromWire,
+} = require('selenium-webdriver/lib/accessibility_snapshot')
+const { Capabilities } = require('selenium-webdriver/lib/capabilities')
+const error = require('selenium-webdriver/lib/error')
+
+const CORPUS = locate('common/src/web/accessibility')
+
+/** The corpus JSON as nodes; `element` holds the recorded id, which the text form never renders. */
+function nodeFromJson(data) {
+ return new AccessibilityNode({
+ role: data.role,
+ name: data.name,
+ description: data.description,
+ value: data.value,
+ states: data.states,
+ element: data.element ?? null,
+ children: data.children.map(nodeFromJson),
+ })
+}
+
+function read(file) {
+ return fs.readFileSync(path.join(CORPUS, file), 'utf8')
+}
+
+describe('AccessibilityNode', function () {
+ describe('toText', function () {
+ for (const page of JSON.parse(read('index.json'))) {
+ it(`renders ${page} as the corpus records it`, function () {
+ const tree = nodeFromJson(JSON.parse(read(`${page}.expected.json`)))
+ assert.strictEqual(tree.toText() + '\n', read(`${page}.expected.txt`))
+ })
+ }
+
+ it('writes one line per node: role, quoted name, states in order, value', function () {
+ const node = new AccessibilityNode({
+ role: 'textbox',
+ name: 'Say "hi" \\ bye',
+ states: { focused: true, disabled: true, level: 2, selected: false, pressed: 'mixed' },
+ value: 'line one\nline two\\',
+ })
+ assert.strictEqual(
+ node.toText(),
+ '- textbox "Say \\"hi\\" \\\\ bye" [pressed=mixed] [level=2] [disabled] [focused]: line one\\nline two\\\\',
+ )
+ assert.strictEqual(new AccessibilityNode({ role: 'list', name: '' }).toText(), '- list')
+ assert.strictEqual(new AccessibilityNode({ role: 'text', name: 'plain words' }).toText(), '- text: plain words')
+ assert.strictEqual(
+ new AccessibilityNode({ role: 'textbox', name: 'Empty', value: '' }).toText(),
+ '- textbox "Empty"',
+ )
+ })
+
+ it('indents children two spaces per level', function () {
+ const tree = new AccessibilityNode({
+ role: 'list',
+ name: '',
+ children: [
+ new AccessibilityNode({
+ role: 'listitem',
+ name: '',
+ children: [
+ new AccessibilityNode({ role: 'text', name: 'one' }),
+ new AccessibilityNode({ role: 'link', name: 'more' }),
+ ],
+ }),
+ ],
+ })
+ assert.strictEqual(tree.toText(), '- list\n - listitem\n - text: one\n - link "more"')
+ })
+ })
+
+ describe('defaults', function () {
+ it('has an empty description, no value, no states, no element and no children', function () {
+ const node = new AccessibilityNode({ role: 'button', name: 'Save' })
+ assert.strictEqual(node.description, '')
+ assert.strictEqual(node.value, undefined)
+ assert.deepStrictEqual(node.states, {})
+ assert.strictEqual(node.element, null)
+ assert.deepStrictEqual(node.children, [])
+ })
+ })
+})
+
+describe('nodeFromWire', function () {
+ it('builds the tree from the JSON form and resolves element and frame indices', async function () {
+ const data = {
+ role: 'generic',
+ name: '',
+ description: '',
+ states: {},
+ element: 0,
+ children: [
+ { role: 'text', name: 'Intro', description: '', states: {}, children: [] },
+ {
+ role: 'textbox',
+ name: 'Email',
+ description: 'Where to write',
+ value: 'a@b.c',
+ states: { focused: true, level: 2, pressed: 'mixed', selected: false },
+ element: 1,
+ children: [],
+ },
+ { role: 'iframe', name: 'Embedded', description: '', states: {}, element: 2, frame: 0, children: [] },
+ ],
+ }
+ const stitched = []
+ const node = await nodeFromWire(data, ['element:0', 'element:1', 'element:2'], async (index) => {
+ stitched.push(index)
+ return [new AccessibilityNode({ role: 'document', name: 'Inner' })]
+ })
+
+ assert.strictEqual(node.role, 'generic')
+ assert.strictEqual(node.element, 'element:0')
+ const [text, textbox, frame] = node.children
+ assert.strictEqual(text.role, 'text')
+ assert.strictEqual(text.element, null)
+ assert.deepStrictEqual(text.children, [])
+ assert.strictEqual(textbox.element, 'element:1')
+ assert.strictEqual(textbox.value, 'a@b.c')
+ assert.strictEqual(textbox.description, 'Where to write')
+ assert.deepStrictEqual(textbox.states, { focused: true, level: 2, pressed: 'mixed', selected: false })
+ assert.strictEqual(frame.element, 'element:2')
+ assert.deepStrictEqual(
+ frame.children.map((child) => child.name),
+ ['Inner'],
+ "the frame's children come from the binding",
+ )
+ assert.deepStrictEqual(stitched, [0])
+ })
+
+ it('keeps absent fields absent', async function () {
+ const node = await nodeFromWire({ role: 'list', name: '', states: {}, children: [] }, [], async () => [])
+
+ assert.strictEqual(node.description, '')
+ assert.strictEqual(node.value, undefined)
+ assert.strictEqual(node.element, null)
+ assert.deepStrictEqual(node.children, [])
+ })
+})
+
+describe('AccessibilitySnapshotter', function () {
+ it('raises UnsupportedOperationError, naming BiDi, for a session without it', async function () {
+ // Only the capability lookup is consulted before the guard fires.
+ const driver = { getCapabilities: async () => new Capabilities() }
+ const snapshotter = new AccessibilitySnapshotter(driver, () => null)
+ await assert.rejects(snapshotter.snapshot(null), (e) => {
+ assert.ok(e instanceof error.UnsupportedOperationError)
+ assert.match(e.message, /BiDi/)
+ return true
+ })
+ })
+
+ it('reads a root another document owns as null and a detached root as the stale error', async function () {
+ const snapshotter = new AccessibilitySnapshotter({}, () => null)
+ const answers = []
+ snapshotter.send_ = async () => ({ type: 'success', result: answers.shift() })
+
+ answers.push({ type: 'null' })
+ assert.strictEqual(await snapshotter.snapshotContext_('ctx', 'shared-1'), null)
+
+ answers.push({ type: 'object', value: [['error', { type: 'string', value: 'detached' }]] })
+ await assert.rejects(snapshotter.snapshotContext_('ctx', 'shared-1'), error.StaleElementReferenceError)
+ })
+
+ it("maps the elements list to elements and a frame index to the frame's context", async function () {
+ const made = []
+ const snapshotter = new AccessibilitySnapshotter({}, (id) => {
+ made.push(id)
+ return `element:${id}`
+ })
+ const contexts = []
+ snapshotter.send_ = async (_method, params) => {
+ contexts.push(params.target.context)
+ if (contexts.length === 1) {
+ return {
+ type: 'success',
+ result: {
+ type: 'object',
+ value: [
+ [
+ 'json',
+ {
+ type: 'string',
+ value: JSON.stringify({
+ role: 'document',
+ name: 'Frames',
+ description: '',
+ states: {},
+ element: 0,
+ children: [
+ { role: 'iframe', name: '', description: '', states: {}, element: 1, frame: 0, children: [] },
+ ],
+ }),
+ },
+ ],
+ [
+ 'elements',
+ {
+ type: 'array',
+ value: [
+ { type: 'node', sharedId: 'top-html' },
+ { type: 'node', sharedId: 'frame-1' },
+ ],
+ },
+ ],
+ ['windows', { type: 'array', value: [{ type: 'window', value: { context: 'child' } }] }],
+ ],
+ },
+ }
+ }
+ return {
+ type: 'success',
+ result: {
+ type: 'object',
+ value: [
+ [
+ 'json',
+ {
+ type: 'string',
+ value: JSON.stringify({
+ role: 'document',
+ name: 'Inner',
+ description: '',
+ states: {},
+ element: 0,
+ children: [],
+ }),
+ },
+ ],
+ ['elements', { type: 'array', value: [{ type: 'node', sharedId: 'child-html' }] }],
+ ['windows', { type: 'array', value: [] }],
+ ],
+ },
+ }
+ }
+
+ const tree = await snapshotter.snapshotContext_('top', null)
+
+ assert.deepStrictEqual(contexts, ['top', 'child'], 'the frame is snapshotted in its own context')
+ assert.strictEqual(tree.element, 'element:top-html')
+ assert.strictEqual(tree.children[0].role, 'iframe')
+ assert.strictEqual(tree.children[0].element, 'element:frame-1')
+ assert.strictEqual(tree.children[0].children[0].name, 'Inner')
+ assert.strictEqual(tree.children[0].children[0].element, 'element:child-html')
+ assert.deepStrictEqual(made, ['top-html', 'frame-1', 'child-html'])
+ })
+})
diff --git a/py/BUILD.bazel b/py/BUILD.bazel
index b95d563b1ef7f..48b524f7234fc 100644
--- a/py/BUILD.bazel
+++ b/py/BUILD.bazel
@@ -161,6 +161,12 @@ copy_file(
out = "selenium/webdriver/remote/findElements.js",
)
+copy_file(
+ name = "accessibility-snapshot",
+ src = "//javascript/atoms/fragments:accessibility-snapshot-wire-typescript.js",
+ out = "selenium/webdriver/remote/accessibilitySnapshot.js",
+)
+
copy_file(
name = "mutation-listener",
src = "//javascript/cdp-support:mutation-listener.js",
@@ -272,6 +278,7 @@ py_library(
name = "remote",
srcs = glob(["selenium/webdriver/remote/**/*.py"]),
data = [
+ ":accessibility-snapshot",
":find-elements",
":get-attribute",
":is-displayed",
diff --git a/py/selenium/webdriver/remote/accessibility_snapshot.py b/py/selenium/webdriver/remote/accessibility_snapshot.py
new file mode 100644
index 0000000000000..e751fb52615c4
--- /dev/null
+++ b/py/selenium/webdriver/remote/accessibility_snapshot.py
@@ -0,0 +1,330 @@
+# 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.
+
+"""Accessibility snapshots computed in the page (ADR 18205).
+
+The tree is computed by the ``accessibility-snapshot`` atom of ``@selenium/atoms``,
+run over WebDriver BiDi in a sandbox realm so page scripts cannot interfere with
+it. The atom's wire entry hands the tree back as one JSON string beside the
+element and frame references, so this module holds only what is the binding's:
+the public node type and its text form, the mapping of references to
+:class:`WebElement`, and frame stitching across browsing contexts.
+
+The output is *computed* from the DOM following WAI-ARIA, html-aam and accname;
+it is not the browser's own accessibility tree and can differ from it at the
+edges.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import pkgutil
+from collections.abc import Callable, Iterator
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+from selenium.common.exceptions import StaleElementReferenceException, WebDriverException
+
+if TYPE_CHECKING:
+ from selenium.webdriver.remote.webdriver import WebDriver
+ from selenium.webdriver.remote.webelement import WebElement
+
+logger = logging.getLogger(__name__)
+
+StateValue = bool | int | str
+"""A value in :attr:`AccessibilityNode.states`: ``True``/``False``, a level, or ``"mixed"``."""
+
+# The order the text form writes states in (ADR 18205 §1).
+_STATE_ORDER = ("checked", "pressed", "expanded", "selected", "level", "disabled", "focused")
+
+# The sandbox realm the atom runs in: page scripts cannot reach its prototypes.
+_SANDBOX = "selenium"
+_SERIALIZATION_OPTIONS = {"maxDomDepth": 0}
+_STALE_MESSAGE = "the snapshot root is no longer attached to a document"
+
+_atom_source: str | None = None
+
+
+@dataclass
+class AccessibilityNode:
+ """One node of an accessibility snapshot.
+
+ Attributes:
+ role: The WAI-ARIA role token; ``"text"`` for text leaves and ``"iframe"``
+ for frames, under which the frame's own tree is attached.
+ name: The computed accessible name, ``""`` when there is none.
+ description: The computed accessible description, ``""`` when there is none.
+ value: The current value for textbox, searchbox, combobox and range roles;
+ ``None`` where the role has none. A password field's value is masked.
+ states: ``checked``, ``pressed``, ``expanded``, ``selected``, ``level``,
+ ``disabled`` and ``focused``, each present only when it applies; new keys
+ may be added in any release, so ignore keys you do not recognise.
+ element: The element the node is backed by, usable for further commands,
+ or ``None`` for text leaves. An element inside a child frame can be
+ used through classic commands only once the driver has switched to
+ that frame.
+ children: The child nodes, ``[]`` for leaves.
+ """
+
+ role: str
+ name: str
+ description: str = ""
+ value: str | None = None
+ states: dict[str, StateValue] = field(default_factory=dict)
+ element: WebElement | None = None
+ children: list[AccessibilityNode] = field(default_factory=list)
+
+ def to_text(self) -> str:
+ """Render the tree as text, one node per line.
+
+ The line grammar is ``- role "name" [state] [state=value]: value``, children
+ indented two spaces, text leaves written as ``- text: content``. An empty
+ name is left out, as is an absent or empty value; a ``True`` state is
+ written bare, a ``False`` one left out, any other value spelled. A line
+ break or backslash in a value is escaped so a node never spans two lines.
+ The description and element references are not rendered.
+
+ Returns:
+ The text form, without a trailing newline.
+ """
+ return "\n".join(self._lines(""))
+
+ def _lines(self, indent: str) -> Iterator[str]:
+ yield indent + self._line()
+ for child in self.children:
+ yield from child._lines(indent + " ")
+
+ def _line(self) -> str:
+ if self.role == "text":
+ return f"- text: {self.name}"
+ out = f"- {self.role}"
+ if self.name != "":
+ out += " " + _quote(self.name)
+ for key in _STATE_ORDER:
+ if key not in self.states:
+ continue
+ state = self.states[key]
+ if state is True:
+ out += f" [{key}]"
+ elif state is not False:
+ out += f" [{key}={state}]"
+ if self.value:
+ out += ": " + _one_line(self.value)
+ return out
+
+
+def _quote(name: str) -> str:
+ return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+def _one_line(value: str) -> str:
+ return value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")
+
+
+def node_from_wire(
+ data: dict[str, Any],
+ elements: list[WebElement],
+ frame_children: Callable[[int], list[AccessibilityNode]],
+) -> AccessibilityNode:
+ """Build the node tree from the atom's wire form.
+
+ Args:
+ data: The parsed JSON of the tree: the node's fields, with ``element`` an
+ index into ``elements`` and, on a frame node, ``frame`` an index the
+ binding resolves to the frame's own tree.
+ elements: The elements the tree references, in the atom's order.
+ frame_children: The children of a frame node, from its ``frame`` index: the
+ frame's document, or nothing when it cannot be reached.
+
+ Returns:
+ The node, with its subtree.
+ """
+ element_index = data.get("element")
+ if "frame" in data:
+ children = frame_children(data["frame"])
+ else:
+ children = [node_from_wire(child, elements, frame_children) for child in data["children"]]
+ return AccessibilityNode(
+ role=data["role"],
+ name=data["name"],
+ description=data.get("description", ""),
+ value=data.get("value"),
+ states=dict(data["states"]),
+ element=elements[element_index] if element_index is not None else None,
+ children=children,
+ )
+
+
+def _atom() -> str:
+ """The atom's wire entry, a JavaScript function expression, read once from the package."""
+ global _atom_source
+ if _atom_source is None:
+ package = ".".join(__name__.split(".")[:-1])
+ data = pkgutil.get_data(package, "accessibilitySnapshot.js")
+ if data is None:
+ raise FileNotFoundError(f"Could not find accessibilitySnapshot.js in package {package}")
+ _atom_source = data.decode("utf8")
+ return _atom_source
+
+
+class AccessibilitySnapshotter:
+ """Takes snapshots for one driver: runs the atom in each document and stitches child frames into the tree."""
+
+ def __init__(self, driver: WebDriver) -> None:
+ self._driver = driver
+
+ def snapshot(self, root: WebElement | None) -> AccessibilityNode:
+ """Snapshot the whole page, or the subtree under ``root``.
+
+ Args:
+ root: The element whose subtree to snapshot, or ``None`` for the page
+ of the current window's top-level browsing context.
+
+ Returns:
+ The snapshot, with every reachable child frame's tree attached under
+ its ``iframe`` node.
+
+ Raises:
+ StaleElementReferenceException: When ``root`` no longer belongs to a document.
+ """
+ top = self._driver.current_window_handle
+ if root is None:
+ node = self._snapshot_context(top, None)
+ if node is None:
+ raise WebDriverException("accessibility snapshot: the script returned nothing")
+ return node
+ # The element may live in any frame of the window. Chromium resolves its shared
+ # id only in the document that owns it; Firefox resolves it in any document of
+ # the browsing context group, so the atom checks ownership itself and each
+ # context is tried until the owner is found.
+ for context in self._contexts(top):
+ try:
+ node = self._snapshot_context(context, root.id)
+ except _NoSuchNode:
+ continue
+ if node is not None:
+ return node
+ raise StaleElementReferenceException(_STALE_MESSAGE)
+
+ def _snapshot_context(self, context: str, root_id: str | None) -> AccessibilityNode | None:
+ """The snapshot of ``context``, or None when another document owns the root."""
+ arguments = [{"sharedId": root_id}] if root_id is not None else []
+ result = self._call(context, arguments)
+ if result.get("type") == "null":
+ return None
+ fields = _object_fields(result)
+ if "error" in fields:
+ # The only error the atom reports as data: the root left its document.
+ raise StaleElementReferenceException(_STALE_MESSAGE)
+ elements = [self._element(_shared_id(node)) for node in fields["elements"].get("value", [])]
+ windows = [_context_of(window) for window in fields["windows"].get("value", [])]
+ return node_from_wire(
+ json.loads(_string(fields["json"])),
+ elements,
+ lambda index: self._stitch(windows[index]),
+ )
+
+ def _call(self, context: str, arguments: list[Any]) -> dict[str, Any]:
+ """Run the atom's wire entry in the sandbox of ``context`` and return the remote value of its result."""
+ try:
+ raw = self._driver.script.call_function(
+ function_declaration=f"(root) => ({_atom()})(root)",
+ await_promise=False,
+ target={"context": context, "sandbox": _SANDBOX},
+ arguments=arguments,
+ result_ownership="none",
+ serialization_options=_SERIALIZATION_OPTIONS,
+ )
+ except WebDriverException as e:
+ # The BiDi "no such node" error: the document of this context does not own
+ # the root's shared id, either because the root lives in another frame or
+ # because its document is gone.
+ if "no such node" in str(e):
+ raise _NoSuchNode() from e
+ raise
+ if not isinstance(raw, dict):
+ raise WebDriverException(f"accessibility snapshot: unexpected script result {raw!r}")
+ if raw.get("type") == "exception":
+ details = raw.get("exceptionDetails") or {}
+ raise WebDriverException(f"accessibility snapshot failed in the page: {details.get('text', details)}")
+ return raw.get("result") or {}
+
+ def _stitch(self, context: str | None) -> list[AccessibilityNode]:
+ """The document of a frame as the frame node's one child, or no children when unreachable."""
+ if context is None:
+ return []
+ try:
+ document = self._snapshot_context(context, None)
+ except (_NoSuchNode, WebDriverException) as e:
+ # Gone between discovery and the call, or mid-navigation: the frame stays
+ # a leaf (ADR §1). Anything else is a real failure.
+ if not _frame_unreachable(e):
+ raise
+ logger.debug("accessibility snapshot: frame %s not reachable: %s", context, e)
+ return []
+ return [document] if document is not None else []
+
+ def _contexts(self, top: str) -> Iterator[str]:
+ """The window's browsing contexts, top-level first, then each frame depth-first."""
+ yield top
+ stack = list(self._driver.browsing_context.get_tree(root=top) or [])
+ while stack:
+ info = stack.pop(0)
+ if info.context != top:
+ yield info.context
+ stack = list(info.children or []) + stack
+
+ def _element(self, shared_id: str) -> WebElement:
+ from selenium.webdriver.remote.webelement import WebElement
+
+ return WebElement(self._driver, shared_id)
+
+
+class _NoSuchNode(Exception):
+ """A shared id that the targeted document does not own."""
+
+
+def _frame_unreachable(e: Exception) -> bool:
+ """Whether an error says a frame or its document is gone, rather than that a command failed."""
+ message = str(e)
+ return isinstance(e, _NoSuchNode) or "no such frame" in message or "no such node" in message
+
+
+def _object_fields(value: dict[str, Any]) -> dict[str, Any]:
+ if value.get("type") != "object":
+ raise WebDriverException(f"accessibility snapshot: expected an object remote value, got {value.get('type')!r}")
+ # A property name is written as a plain string, or as a string remote value.
+ return {key if isinstance(key, str) else _string(key): entry for key, entry in value.get("value", [])}
+
+
+def _string(value: dict[str, Any]) -> str:
+ return str(value.get("value", ""))
+
+
+def _shared_id(node: dict[str, Any]) -> str:
+ shared_id = node.get("sharedId")
+ if node.get("type") != "node" or not shared_id:
+ raise WebDriverException("accessibility snapshot: expected a node reference in the elements list")
+ return str(shared_id)
+
+
+def _context_of(window: dict[str, Any]) -> str | None:
+ """The browsing context id of a window remote value; None for a frame with no reachable context."""
+ if window.get("type") != "window":
+ return None
+ return (window.get("value") or {}).get("context")
diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py
index c03c6cc642ecc..b679a79f2f6ac 100644
--- a/py/selenium/webdriver/remote/webdriver.py
+++ b/py/selenium/webdriver/remote/webdriver.py
@@ -67,6 +67,7 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.options import ArgOptions, BaseOptions
from selenium.webdriver.common.web_extension import WebExtension
+from selenium.webdriver.remote.accessibility_snapshot import AccessibilityNode, AccessibilitySnapshotter
from selenium.webdriver.remote.bidi_connection import BidiConnection
from selenium.webdriver.remote.client_config import ClientConfig
from selenium.webdriver.remote.command import Command
@@ -358,6 +359,7 @@ def __init__(
self._websocket_connection: WebSocketConnection | None = None
self._script: Script | None = None
+ self._accessibility_snapshotter: AccessibilitySnapshotter | None = None
self._network: Network | None = None
self._browser: Browser | None = None
self._bidi_session: Session | None = None
@@ -749,6 +751,40 @@ def minimize_window(self) -> None:
"""Invokes the window manager-specific 'minimize' operation."""
self.execute(Command.MINIMIZE_WINDOW)
+ def accessibility_snapshot(self) -> AccessibilityNode:
+ """Snapshot the accessibility tree of the page.
+
+ The tree is computed in the page from the DOM, following WAI-ARIA,
+ html-aam and accname; it is not the browser's own accessibility tree.
+ It is rooted at the top-level browsing context of the current window,
+ whatever frame the driver has switched to, and every child frame's
+ tree is attached under its ``iframe`` node. To scope to a frame, take
+ ``element.accessibility_snapshot()`` of its ``iframe`` element. The
+ snapshot is a copy taken at one point in time.
+
+ Returns:
+ The root node, of role ``document``, named from the title.
+
+ Raises:
+ WebDriverException: When the session has no WebDriver BiDi connection.
+
+ Example:
+ >>> tree = driver.accessibility_snapshot()
+ >>> print(tree.to_text())
+ >>> tree.children[0].element.click()
+ """
+ return self._accessibility().snapshot(None)
+
+ def _accessibility(self) -> AccessibilitySnapshotter:
+ if not self.caps.get("webSocketUrl"):
+ raise WebDriverException(
+ "accessibility_snapshot() requires a WebDriver BiDi session; "
+ "enable it with options.enable_bidi = True before starting the driver"
+ )
+ if self._accessibility_snapshotter is None:
+ self._accessibility_snapshotter = AccessibilitySnapshotter(self)
+ return self._accessibility_snapshotter
+
def print_page(self, print_options: PrintOptions | None = None) -> str:
"""Takes PDF of the current page.
diff --git a/py/selenium/webdriver/remote/webelement.py b/py/selenium/webdriver/remote/webelement.py
index 82e6192d5285a..5fc6a68904f30 100644
--- a/py/selenium/webdriver/remote/webelement.py
+++ b/py/selenium/webdriver/remote/webelement.py
@@ -29,6 +29,7 @@
from selenium.common.exceptions import JavascriptException, WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.utils import keys_to_typing
+from selenium.webdriver.remote.accessibility_snapshot import AccessibilityNode
from selenium.webdriver.remote.command import Command
from selenium.webdriver.remote.shadowroot import ShadowRoot
@@ -428,6 +429,23 @@ def screenshot_as_png(self) -> bytes:
"""
return b64decode(self.screenshot_as_base64.encode("ascii"))
+ def accessibility_snapshot(self) -> AccessibilityNode:
+ """Snapshot the accessibility tree of this element's subtree.
+
+ The root node is always this element, whatever its role; if the element
+ is hidden, by itself or by an ancestor, that node has no children. Child
+ frames are attached under their ``iframe`` nodes. See
+ :meth:`WebDriver.accessibility_snapshot`.
+
+ Returns:
+ The node for this element, with its subtree.
+
+ Raises:
+ WebDriverException: When the session has no WebDriver BiDi connection.
+ StaleElementReferenceException: When the element is no longer attached.
+ """
+ return self._parent._accessibility().snapshot(self)
+
def screenshot(self, filename) -> bool:
"""Save a PNG screenshot of the current element to a file.
diff --git a/py/test/selenium/webdriver/common/accessibility_snapshot_tests.py b/py/test/selenium/webdriver/common/accessibility_snapshot_tests.py
new file mode 100644
index 0000000000000..469c2a37c8fcf
--- /dev/null
+++ b/py/test/selenium/webdriver/common/accessibility_snapshot_tests.py
@@ -0,0 +1,45 @@
+# 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.
+
+"""Accessibility snapshots need BiDi (ADR 18205).
+
+A WebDriver Classic session raises rather than returning a partial tree. This file runs in
+the classic suites only.
+"""
+
+import pytest
+
+from selenium.common.exceptions import WebDriverException
+from selenium.webdriver.common.by import By
+
+
+def test_classic_session_raises_for_the_whole_page(driver, pages):
+ pages.load("accessibility/001-headings-and-landmarks.html")
+ if driver.capabilities.get("webSocketUrl"):
+ pytest.skip("this session has BiDi")
+
+ with pytest.raises(WebDriverException, match="BiDi"):
+ driver.accessibility_snapshot()
+
+
+def test_classic_session_raises_for_an_element(driver, pages):
+ pages.load("accessibility/001-headings-and-landmarks.html")
+ if driver.capabilities.get("webSocketUrl"):
+ pytest.skip("this session has BiDi")
+
+ with pytest.raises(WebDriverException, match="BiDi"):
+ driver.find_element(By.TAG_NAME, "body").accessibility_snapshot()
diff --git a/py/test/selenium/webdriver/common/bidi/accessibility_snapshot_tests.py b/py/test/selenium/webdriver/common/bidi/accessibility_snapshot_tests.py
new file mode 100644
index 0000000000000..4d5c5455a2d62
--- /dev/null
+++ b/py/test/selenium/webdriver/common/bidi/accessibility_snapshot_tests.py
@@ -0,0 +1,218 @@
+# 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.
+
+"""driver.accessibility_snapshot() and element.accessibility_snapshot() over BiDi (ADR 18205).
+
+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.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from selenium.common.exceptions import StaleElementReferenceException
+from selenium.webdriver.common.by import By
+from selenium.webdriver.remote.accessibility_snapshot import AccessibilityNode
+from selenium.webdriver.remote.webelement import WebElement
+
+CORPUS = Path(__file__).parents[6] / "common" / "src" / "web" / "accessibility"
+
+# The atom records frames as leaves; the binding fills them in, so this page has its own test.
+FRAMES_PAGE = "014-iframe"
+
+
+def _corpus_pages():
+ return [page for page in json.loads((CORPUS / "index.json").read_text(encoding="utf-8")) if page != FRAMES_PAGE]
+
+
+def _engine(driver):
+ name = driver.capabilities.get("browserName", "").lower()
+ if "firefox" in name:
+ return "firefox"
+ if "safari" in name or "webkit" in name:
+ return "webkit"
+ return "chromium"
+
+
+def _expected(driver, page, suffix):
+ """The engine-specific expectation when one is recorded, else the shared one."""
+ specific = CORPUS / f"{page}.expected.{_engine(driver)}.{suffix}"
+ shared = CORPUS / f"{page}.expected.{suffix}"
+ return (specific if specific.exists() else shared).read_text(encoding="utf-8")
+
+
+def _serialize(node):
+ """The corpus JSON form: every field written, `element` as the element's id."""
+ out = {"role": node.role, "name": node.name, "description": node.description}
+ if node.value is not None:
+ out["value"] = node.value
+ out["states"] = node.states
+ if node.element is not None:
+ out["element"] = node.element.get_attribute("id")
+ out["children"] = [_serialize(child) for child in node.children]
+ return out
+
+
+@pytest.mark.parametrize("page", _corpus_pages())
+def test_whole_page_snapshot_matches_the_corpus(driver, pages, page):
+ pages.load(f"accessibility/{page}.html")
+
+ tree = driver.accessibility_snapshot()
+
+ assert _serialize(tree) == json.loads(_expected(driver, page, "json"))
+ assert tree.to_text() + "\n" == _expected(driver, page, "txt")
+
+
+def test_whole_page_snapshot_is_rooted_at_the_document(driver, pages):
+ pages.load("accessibility/001-headings-and-landmarks.html")
+
+ tree = driver.accessibility_snapshot()
+
+ assert isinstance(tree, AccessibilityNode)
+ assert tree.role == "document"
+ assert isinstance(tree.element, WebElement)
+ assert tree.element.tag_name == "html"
+
+
+def test_element_references_are_usable(driver, pages):
+ pages.load("accessibility/003-links-and-buttons.html")
+
+ tree = driver.accessibility_snapshot()
+
+ buttons = [node for node in _walk(tree) if node.role == "button"]
+ assert buttons, "the page has buttons"
+ assert buttons[0].element.tag_name in ("button", "input", "a")
+ assert buttons[0].element.get_attribute("id") == _first_button_id(tree)
+
+
+def test_subtree_snapshot_always_produces_a_node_for_its_root(driver, pages):
+ pages.load("accessibility/005-lists.html")
+ root = driver.find_element(By.TAG_NAME, "body")
+
+ tree = root.accessibility_snapshot()
+
+ assert tree.role == "generic"
+ assert tree.element.tag_name == "body"
+ assert any(node.role == "list" for node in _walk(tree))
+
+
+def test_subtree_snapshot_of_a_node_from_an_earlier_snapshot(driver, pages):
+ pages.load("accessibility/005-lists.html")
+ first_list = next(node for node in _walk(driver.accessibility_snapshot()) if node.role == "list")
+
+ tree = first_list.element.accessibility_snapshot()
+
+ assert tree.role == "list"
+ assert [child.role for child in tree.children] == [child.role for child in first_list.children]
+
+
+def test_frames_are_stitched_in(driver, pages):
+ pages.load(f"accessibility/{FRAMES_PAGE}.html")
+
+ tree = driver.accessibility_snapshot()
+
+ frames = [node for node in _walk(tree) if node.role == "iframe"]
+ assert [frame.name for frame in frames] == ["Embedded content", ""]
+ for frame in frames:
+ assert len(frame.children) == 1, "a reachable frame carries its document as the one child"
+ assert frame.children[0].role == "document"
+ assert isinstance(frame.children[0].element, WebElement)
+ # The text form renders the stitched tree; the atom-level expectation stops at the leaves.
+ assert tree.to_text().count("- document") == 3
+ # A reference inside a frame works through classic commands once that frame is current.
+ driver.switch_to.frame(frames[0].element)
+ try:
+ assert frames[0].children[0].element.tag_name == "html"
+ finally:
+ driver.switch_to.default_content()
+
+
+def test_subtree_snapshot_of_an_iframe_element_carries_its_document(driver, pages):
+ pages.load(f"accessibility/{FRAMES_PAGE}.html")
+ frame = driver.find_element(By.ID, "frame")
+
+ tree = frame.accessibility_snapshot()
+
+ assert tree.role == "iframe"
+ assert tree.name == "Embedded content"
+ assert [child.role for child in tree.children] == ["document"]
+
+
+def test_subtree_snapshot_inside_a_frame(driver, pages):
+ pages.load(f"accessibility/{FRAMES_PAGE}.html")
+ driver.switch_to.frame("frame")
+ try:
+ body = driver.find_element(By.TAG_NAME, "body")
+ tree = body.accessibility_snapshot()
+ # The root is the frame's body, not the top document that owns the window.
+ assert tree.role == "generic"
+ assert tree.name == ""
+ assert tree.element.tag_name == "body"
+ finally:
+ driver.switch_to.default_content()
+
+
+def test_whole_page_snapshot_ignores_the_frame_the_driver_switched_to(driver, pages):
+ pages.load(f"accessibility/{FRAMES_PAGE}.html")
+ driver.switch_to.frame("frame")
+ try:
+ tree = driver.accessibility_snapshot()
+ finally:
+ driver.switch_to.default_content()
+
+ assert tree.name == "Frames"
+
+
+def test_a_stale_root_raises_the_stale_element_error(driver, pages):
+ pages.load("accessibility/005-lists.html")
+ root = driver.find_element(By.TAG_NAME, "body")
+ pages.load("accessibility/001-headings-and-landmarks.html")
+
+ with pytest.raises(StaleElementReferenceException):
+ root.accessibility_snapshot()
+
+
+def test_a_detached_root_raises_the_stale_element_error(driver, pages):
+ pages.load("accessibility/005-lists.html")
+ item = driver.find_element(By.TAG_NAME, "li")
+ driver.execute_script("arguments[0].remove()", item)
+
+ with pytest.raises(StaleElementReferenceException):
+ item.accessibility_snapshot()
+
+
+def test_a_snapshot_is_a_copy(driver, pages):
+ pages.load("accessibility/005-lists.html")
+ before = driver.accessibility_snapshot()
+ driver.execute_script("document.querySelector('li').textContent = 'changed'")
+
+ after = driver.accessibility_snapshot()
+
+ assert before.to_text() != after.to_text()
+ assert "changed" in after.to_text()
+
+
+def _walk(node):
+ yield node
+ for child in node.children:
+ yield from _walk(child)
+
+
+def _first_button_id(tree):
+ return next(node for node in _walk(tree) if node.role == "button").element.get_attribute("id")
diff --git a/py/test/unit/selenium/webdriver/common/accessibility_snapshot_tests.py b/py/test/unit/selenium/webdriver/common/accessibility_snapshot_tests.py
new file mode 100644
index 0000000000000..6aa8f4637bf9c
--- /dev/null
+++ b/py/test/unit/selenium/webdriver/common/accessibility_snapshot_tests.py
@@ -0,0 +1,150 @@
+# 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.
+
+"""The pure parts of the accessibility snapshot (ADR 18205).
+
+The text renderer is held to the shared corpus, and the decoder to the BiDi wire shape.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from selenium.webdriver.remote.accessibility_snapshot import AccessibilityNode, node_from_wire
+
+CORPUS = Path(__file__).parents[6] / "common" / "src" / "web" / "accessibility"
+
+
+def _node_from_json(data):
+ """The corpus JSON as nodes; `element` holds the recorded id, which the text form never renders."""
+ return AccessibilityNode(
+ role=data["role"],
+ name=data["name"],
+ description=data["description"],
+ value=data.get("value"),
+ states=data["states"],
+ element=data.get("element"),
+ children=[_node_from_json(child) for child in data["children"]],
+ )
+
+
+def _corpus_pages():
+ return json.loads((CORPUS / "index.json").read_text(encoding="utf-8"))
+
+
+@pytest.mark.parametrize("page", _corpus_pages())
+def test_to_text_matches_the_corpus(page):
+ """Every expected.txt is the reference renderer's output over its expected.json; the port must agree."""
+ tree = _node_from_json(json.loads((CORPUS / f"{page}.expected.json").read_text(encoding="utf-8")))
+ expected = (CORPUS / f"{page}.expected.txt").read_text(encoding="utf-8")
+ assert tree.to_text() + "\n" == expected
+
+
+def test_to_text_line_grammar():
+ node = AccessibilityNode(
+ role="textbox",
+ name='Say "hi" \\ bye',
+ states={"focused": True, "disabled": True, "level": 2, "selected": False, "pressed": "mixed"},
+ value="line one\nline two\\",
+ )
+ expected = (
+ '- textbox "Say \\"hi\\" \\\\ bye" [pressed=mixed] [level=2] [disabled] [focused]: line one\\nline two\\\\'
+ )
+ assert node.to_text() == expected
+ assert AccessibilityNode(role="list", name="").to_text() == "- list"
+ assert AccessibilityNode(role="text", name="plain words").to_text() == "- text: plain words"
+ assert AccessibilityNode(role="textbox", name="Empty", value="").to_text() == '- textbox "Empty"'
+
+
+def test_to_text_indents_children_two_spaces_per_level():
+ tree = AccessibilityNode(
+ role="list",
+ name="",
+ children=[
+ AccessibilityNode(
+ role="listitem",
+ name="",
+ children=[AccessibilityNode(role="text", name="one"), AccessibilityNode(role="link", name="more")],
+ )
+ ],
+ )
+ assert tree.to_text() == '- list\n - listitem\n - text: one\n - link "more"'
+
+
+def test_node_from_wire_builds_the_tree_and_resolves_the_indices():
+ """The atom's wire form: element indices into the elements list, a frame index for frame nodes."""
+ data = {
+ "role": "generic",
+ "name": "",
+ "description": "",
+ "states": {},
+ "element": 0,
+ "children": [
+ {"role": "text", "name": "Intro", "description": "", "states": {}, "children": []},
+ {
+ "role": "textbox",
+ "name": "Email",
+ "description": "Where to write",
+ "value": "a@b.c",
+ "states": {"focused": True, "level": 2, "pressed": "mixed", "selected": False},
+ "element": 1,
+ "children": [],
+ },
+ {
+ "role": "iframe",
+ "name": "Embedded",
+ "description": "",
+ "states": {},
+ "element": 2,
+ "frame": 0,
+ "children": [],
+ },
+ ],
+ }
+ elements = ["element:0", "element:1", "element:2"]
+ stitched = []
+
+ def frame_children(index):
+ stitched.append(index)
+ return [AccessibilityNode(role="document", name="Inner")]
+
+ node = node_from_wire(data, elements, frame_children)
+
+ assert node.role == "generic"
+ assert node.element == "element:0"
+ text, textbox, frame = node.children
+ assert text.role == "text"
+ assert text.element is None
+ assert text.children == []
+ assert textbox.element == "element:1"
+ assert textbox.value == "a@b.c"
+ assert textbox.description == "Where to write"
+ assert textbox.states == {"focused": True, "level": 2, "pressed": "mixed", "selected": False}
+ assert isinstance(textbox.states["level"], int)
+ assert frame.element == "element:2"
+ assert [child.name for child in frame.children] == ["Inner"], "the frame's children come from the binding"
+ assert stitched == [0]
+
+
+def test_node_from_wire_keeps_absent_fields_absent():
+ node = node_from_wire({"role": "list", "name": "", "states": {}, "children": []}, [], lambda index: [])
+
+ assert node.description == ""
+ assert node.value is None
+ assert node.element is None
+ assert node.children == []
diff --git a/rake_tasks/python.rake b/rake_tasks/python.rake
index b0986a6198dc3..d44d16629280c 100644
--- a/rake_tasks/python.rake
+++ b/rake_tasks/python.rake
@@ -63,7 +63,7 @@ task :local_dev, [:all] do |_task, arguments|
else
dirs = %w[common/bidi common/_bidi common/devtools]
files = %w[
- remote/getAttribute.js remote/isDisplayed.js remote/findElements.js
+ remote/getAttribute.js remote/isDisplayed.js remote/findElements.js remote/accessibilitySnapshot.js
common/mutation-listener.js common/bidi-mutation-listener.js
common/linux-x86_64/selenium-manager common/linux-arm64/selenium-manager
common/macos/selenium-manager common/windows/selenium-manager.exe
diff --git a/rb/lib/selenium/webdriver/BUILD.bazel b/rb/lib/selenium/webdriver/BUILD.bazel
index 5d6cc4825084f..e4881764cb305 100644
--- a/rb/lib/selenium/webdriver/BUILD.bazel
+++ b/rb/lib/selenium/webdriver/BUILD.bazel
@@ -13,6 +13,7 @@ rb_library(
name = "atoms",
srcs = ["atoms.rb"],
data = [
+ "//rb/lib/selenium/webdriver/atoms:accessibility-snapshot",
"//rb/lib/selenium/webdriver/atoms:find-elements",
"//rb/lib/selenium/webdriver/atoms:get-attribute",
"//rb/lib/selenium/webdriver/atoms:is-displayed",
diff --git a/rb/lib/selenium/webdriver/atoms/BUILD.bazel b/rb/lib/selenium/webdriver/atoms/BUILD.bazel
index dacfad75a0f03..2a6566a25d88f 100644
--- a/rb/lib/selenium/webdriver/atoms/BUILD.bazel
+++ b/rb/lib/selenium/webdriver/atoms/BUILD.bazel
@@ -2,6 +2,12 @@ load("//common:defs.bzl", "copy_file")
package(default_visibility = ["//rb:__subpackages__"])
+copy_file(
+ name = "accessibility-snapshot",
+ src = "//javascript/atoms/fragments:accessibility-snapshot-wire-typescript.js",
+ out = "accessibilitySnapshot.js",
+)
+
copy_file(
name = "find-elements",
src = "//javascript/atoms/fragments:find-elements-typescript.js",
diff --git a/rb/lib/selenium/webdriver/common.rb b/rb/lib/selenium/webdriver/common.rb
index 609dcc8788bac..3b3e06cd46f4d 100644
--- a/rb/lib/selenium/webdriver/common.rb
+++ b/rb/lib/selenium/webdriver/common.rb
@@ -94,6 +94,7 @@
require 'selenium/webdriver/common/takes_screenshot'
require 'selenium/webdriver/common/driver'
require 'selenium/webdriver/common/element'
+require 'selenium/webdriver/common/accessibility_snapshot'
require 'selenium/webdriver/common/web_extension'
require 'selenium/webdriver/common/shadow_root'
require 'selenium/webdriver/common/websocket_connection'
diff --git a/rb/lib/selenium/webdriver/common/accessibility_snapshot.rb b/rb/lib/selenium/webdriver/common/accessibility_snapshot.rb
new file mode 100644
index 0000000000000..741c6908cc1c6
--- /dev/null
+++ b/rb/lib/selenium/webdriver/common/accessibility_snapshot.rb
@@ -0,0 +1,338 @@
+# frozen_string_literal: true
+
+# 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.
+
+module Selenium
+ module WebDriver
+ #
+ # One node of an accessibility snapshot (ADR 18205), as returned by
+ # {Driver#accessibility_snapshot} and {Element#accessibility_snapshot}.
+ #
+ # The tree is computed in the page from the DOM following WAI-ARIA, html-aam and
+ # accname. It is not the browser's own accessibility tree and can differ from it at
+ # the edges.
+ #
+
+ class AccessibilityNode
+ # The order the text form writes states in (ADR 18205 §1).
+ STATE_ORDER = %w[checked pressed expanded selected level disabled focused].freeze
+
+ # @return [String] the WAI-ARIA role token; 'text' for text leaves and 'iframe' for
+ # frames, under which the frame's own tree is attached
+ attr_reader :role
+
+ # @return [String] the computed accessible name, '' when there is none
+ attr_reader :name
+
+ # @return [String] the computed accessible description, '' when there is none
+ attr_reader :description
+
+ # @return [String, nil] the current value for textbox, searchbox, combobox and range
+ # roles; nil where the role has none. A password field's value is masked.
+ attr_reader :value
+
+ # @return [Hash{String => Boolean, Integer, String}] checked, pressed, expanded,
+ # selected, level, disabled and focused, each present only when it applies; new
+ # keys may be added in any release, so ignore keys you do not recognise
+ attr_reader :states
+
+ # @return [Element, nil] the element the node is backed by, usable for further
+ # commands, or nil for text leaves. An element inside a child frame can be used
+ # through classic commands only once the driver has switched to that frame.
+ attr_reader :element
+
+ # @return [Array] the child nodes, [] for leaves
+ attr_accessor :children
+
+ def initialize(role:, name:, description: '', value: nil, states: {}, element: nil, children: [])
+ @role = role
+ @name = name
+ @description = description
+ @value = value
+ @states = states
+ @element = element
+ @children = children
+ end
+
+ #
+ # Renders the tree as text, one node per line.
+ #
+ # The line grammar is - role "name" [state] [state=value]: value , children
+ # indented two spaces, text leaves written as - text: content . An empty name
+ # is left out, as is an absent or empty value; a true state is written bare, a false
+ # one left out, any other value spelled. A line break or backslash in a value is
+ # escaped so a node never spans two lines. The description and element references
+ # are not rendered.
+ #
+ # @return [String] the text form, without a trailing newline
+ #
+
+ def to_text
+ lines('').join("\n")
+ end
+
+ #
+ # Builds the node tree from the atom's wire form: the parsed JSON of the tree, in
+ # which +element+ is an index into +elements+ and, on a frame node, +frame+ an index
+ # the block resolves to the frame's own tree (its document, or nothing when it
+ # cannot be reached).
+ #
+ # @api private
+ #
+
+ def self.from_wire(data, elements, &frame_children)
+ children = if data.key?('frame')
+ yield data['frame']
+ else
+ data.fetch('children').map { |child| from_wire(child, elements, &frame_children) }
+ end
+ element_index = data['element']
+ new(
+ role: data.fetch('role'),
+ name: data.fetch('name'),
+ description: data.fetch('description', ''),
+ value: data['value'],
+ states: data.fetch('states').dup,
+ element: element_index && elements.fetch(element_index),
+ children: children
+ )
+ end
+
+ # The BiDi remote value shapes the snapshotter reads around the JSON text.
+ #
+ # @api private
+ module Wire
+ module_function
+
+ def object_fields(value)
+ unless value['type'] == 'object'
+ raise Error::WebDriverError,
+ "accessibility snapshot: expected an object remote value, got #{value['type'].inspect}"
+ end
+
+ # A property name is written as a plain string, or as a string remote value.
+ value.fetch('value', []).to_h { |key, entry| [key.is_a?(String) ? key : string(key), entry] }
+ end
+
+ def string(value)
+ value.fetch('value', '').to_s
+ end
+
+ def shared_id(node)
+ shared_id = node['sharedId']
+ unless node['type'] == 'node' && shared_id
+ raise Error::WebDriverError, 'accessibility snapshot: expected a node reference in the elements list'
+ end
+
+ shared_id.to_s
+ end
+
+ # The browsing context id of a window remote value; nil for a frame with no reachable context.
+ def context_of(window)
+ return nil unless window['type'] == 'window'
+
+ window.dig('value', 'context')
+ end
+ end
+
+ protected
+
+ def lines(indent)
+ [indent + line] + children.flat_map { |child| child.lines("#{indent} ") }
+ end
+
+ private
+
+ def line
+ return "- text: #{name}" if role == 'text'
+
+ out = "- #{role}"
+ out += " #{quote(name)}" unless name.empty?
+ out += state_tokens
+ out += ": #{one_line(value)}" if value && !value.empty?
+ out
+ end
+
+ # A true state bare, a false one left out, any other value spelled.
+ def state_tokens
+ tokens = STATE_ORDER.filter_map do |key|
+ next unless states.key?(key)
+
+ state = states[key]
+ next if state == false
+
+ state == true ? " [#{key}]" : " [#{key}=#{state}]"
+ end
+ tokens.join
+ end
+
+ def quote(text)
+ "\"#{text.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }}\""
+ end
+
+ def one_line(text)
+ text.gsub('\\') { '\\\\' }.gsub("\r") { '\\r' }.gsub("\n") { '\\n' }
+ end
+ end # AccessibilityNode
+
+ #
+ # Takes accessibility snapshots for one BiDi session: runs the atom's wire entry in
+ # the sandbox realm of each document and stitches child frames into the tree.
+ #
+ # @api private
+ #
+
+ class AccessibilitySnapshotter
+ include Atoms
+
+ # The sandbox realm the atom runs in: page scripts cannot reach its prototypes.
+ SANDBOX = 'selenium'
+ SERIALIZATION_OPTIONS = {maxDomDepth: 0}.freeze
+ STALE_MESSAGE = 'the snapshot root is no longer attached to a document'
+
+ # The document of the targeted context does not own the root: the BiDi "no such node"
+ # error, or the script's null answer to a root another document owns. Internal to the
+ # search over the window's contexts, so it is not one of the registered BiDi errors.
+ #
+ # @api private
+ class NotOwnedError < Error::WebDriverError; end
+
+ def initialize(bridge)
+ @bridge = bridge
+ @bidi = bridge.bidi
+ end
+
+ #
+ # Snapshots the whole page, or the subtree under +root+.
+ #
+ # @param root [Element, nil] the element whose subtree to snapshot, or nil for the
+ # page of the current window's top-level browsing context
+ # @return [AccessibilityNode] the snapshot, with every reachable child frame's tree
+ # attached under its +iframe+ node
+ # @raise [Error::StaleElementReferenceError] when +root+ no longer belongs to a document
+ #
+
+ def snapshot(root = nil)
+ top = @bridge.window_handle
+ if root.nil?
+ node = snapshot_context(top, nil)
+ raise Error::WebDriverError, 'accessibility snapshot: the script returned nothing' if node.nil?
+
+ return node
+ end
+
+ # The element may live in any frame of the window. Chromium resolves its shared
+ # id only in the document that owns it; Firefox resolves it in any document of
+ # the browsing context group, so the atom checks ownership itself and each
+ # context is tried until the owner is found.
+ contexts(top).each do |context|
+ node = snapshot_context(context, root.ref.last)
+ return node unless node.nil?
+ rescue NotOwnedError
+ next
+ end
+ raise Error::StaleElementReferenceError, STALE_MESSAGE
+ end
+
+ private
+
+ # The snapshot of +context+, or nil when another document owns the root.
+ def snapshot_context(context, root_id)
+ result = call(context, root_id ? [{sharedId: root_id}] : [])
+ return nil if result['type'] == 'null'
+
+ fields = AccessibilityNode::Wire.object_fields(result)
+ # The only error the atom reports as data: the root left its document.
+ raise Error::StaleElementReferenceError, STALE_MESSAGE if fields.key?('error')
+
+ elements = fields.fetch('elements').fetch('value', []).map { |node| element(AccessibilityNode::Wire.shared_id(node)) }
+ windows = fields.fetch('windows').fetch('value', []).map { |window| AccessibilityNode::Wire.context_of(window) }
+ data = JSON.parse(AccessibilityNode::Wire.string(fields.fetch('json')))
+ AccessibilityNode.from_wire(data, elements) { |index| stitch(windows.fetch(index)) }
+ end
+
+ # Runs the atom's wire entry in the sandbox of +context+ and returns the remote value of its result.
+ def call(context, arguments)
+ raw = @bidi.send_cmd('script.callFunction',
+ functionDeclaration: "(root) => (#{atom})(root)",
+ awaitPromise: false,
+ target: {context: context, sandbox: SANDBOX},
+ arguments: arguments,
+ resultOwnership: 'none',
+ serializationOptions: SERIALIZATION_OPTIONS)
+ return raw['result'] || {} unless raw['type'] == 'exception'
+
+ details = raw['exceptionDetails'] || {}
+ raise Error::WebDriverError, "accessibility snapshot failed in the page: #{details['text'] || details}"
+ rescue Error::WebDriverError => e
+ # The BiDi "no such node" error: the document of this context does not own the
+ # root's shared id, either because the root lives in another frame or because
+ # its document is gone.
+ raise NotOwnedError, e.message if e.message.include?('no such node')
+
+ raise
+ end
+
+ # The document of a frame as the frame node's one child, or no children when unreachable.
+ def stitch(context)
+ return [] if context.nil?
+
+ document = snapshot_context(context, nil)
+ document.nil? ? [] : [document]
+ rescue Error::WebDriverError => e
+ # Gone between discovery and the call, or mid-navigation: the frame stays a
+ # leaf (ADR §1). Anything else is a real failure.
+ raise unless frame_unreachable?(e)
+
+ WebDriver.logger.debug("accessibility snapshot: frame #{context} not reachable: #{e.message}",
+ id: :accessibility_snapshot)
+ []
+ end
+
+ # Through the bridge's configured element class, as every other element lookup.
+ def element(shared_id)
+ Remote::Bridge.element_class.new(@bridge, shared_id)
+ end
+
+ # The window's browsing contexts, top-level first, then each frame depth-first.
+ def contexts(top)
+ tree = @bidi.send_cmd('browsingContext.getTree', root: top)
+ contexts = [top]
+ stack = tree['contexts'] || []
+ until stack.empty?
+ info = stack.shift
+ contexts << info['context'] unless info['context'] == top
+ stack = (info['children'] || []) + stack
+ end
+ contexts
+ end
+
+ # Whether an error says a frame or its document is gone, rather than that a command failed.
+ def frame_unreachable?(error)
+ return true if error.is_a?(NotOwnedError)
+
+ error.message.include?('no such frame') || error.message.include?('no such node')
+ end
+
+ # The atom's wire entry, a JavaScript function expression, read once from the gem.
+ def atom
+ @atom ||= read_atom(:accessibilitySnapshot)
+ end
+ end # AccessibilitySnapshotter
+ end # WebDriver
+end # Selenium
diff --git a/rb/lib/selenium/webdriver/common/driver.rb b/rb/lib/selenium/webdriver/common/driver.rb
index 21ed1a240f7d1..876df93bcf1a8 100644
--- a/rb/lib/selenium/webdriver/common/driver.rb
+++ b/rb/lib/selenium/webdriver/common/driver.rb
@@ -277,6 +277,22 @@ def network
@network ||= WebDriver::Network.new(bridge)
end
+ #
+ # Takes an accessibility snapshot of the whole page: the tree of roles, names,
+ # states and values computed from the DOM (ADR 18205), rooted at the document of
+ # the current window's top-level browsing context whatever frame the driver has
+ # switched to, with every reachable child frame's tree attached under its iframe
+ # node. Requires a BiDi session.
+ #
+ # @return [AccessibilityNode] the root of the snapshot
+ # @raise [Error::UnsupportedOperationError] when the session has no BiDi connection
+ # @see AccessibilityNode#to_text
+ #
+
+ def accessibility_snapshot
+ bridge.accessibility_snapshot
+ end
+
#
# Installs a browser extension from an unpacked directory, a packed extension (.xpi/.crx/.zip),
# or base64-encoded bytes; works with remote (Grid) sessions.
diff --git a/rb/lib/selenium/webdriver/common/element.rb b/rb/lib/selenium/webdriver/common/element.rb
index 48b506ecc7a62..529983bce0611 100644
--- a/rb/lib/selenium/webdriver/common/element.rb
+++ b/rb/lib/selenium/webdriver/common/element.rb
@@ -180,6 +180,21 @@ def accessible_name
bridge.element_aria_label @id
end
+ #
+ # Takes an accessibility snapshot of the subtree under this element (ADR 18205).
+ # The element always produces the root node; an iframe carries its document.
+ # Requires a BiDi session.
+ #
+ # @return [AccessibilityNode] the node for this element, with its subtree
+ # @raise [Error::UnsupportedOperationError] when the session has no BiDi connection
+ # @raise [Error::StaleElementReferenceError] when the element no longer belongs to a document
+ # @see Driver#accessibility_snapshot
+ #
+
+ def accessibility_snapshot
+ bridge.accessibility_snapshot(self)
+ end
+
#
# Get the text content of this element
#
diff --git a/rb/lib/selenium/webdriver/remote/bridge.rb b/rb/lib/selenium/webdriver/remote/bridge.rb
index 1f17cf0893ce5..4181fc7d63976 100644
--- a/rb/lib/selenium/webdriver/remote/bridge.rb
+++ b/rb/lib/selenium/webdriver/remote/bridge.rb
@@ -421,6 +421,18 @@ def element_aria_label(element)
execute :get_element_aria_label, id: element
end
+ # The snapshot runs in a BiDi sandbox realm; a classic-only session cannot take one.
+ def accessibility_snapshot(root = nil)
+ unless bidi?
+ raise Error::UnsupportedOperationError,
+ 'accessibility_snapshot requires WebDriver BiDi; ' \
+ 'enable it by setting #web_socket_url to true in options class'
+ end
+
+ @accessibility_snapshotter ||= AccessibilitySnapshotter.new(self)
+ @accessibility_snapshotter.snapshot(root)
+ end
+
def element_value(element)
element_property element, 'value'
end
diff --git a/rb/sig/lib/selenium/webdriver/common/accessibility_snapshot.rbs b/rb/sig/lib/selenium/webdriver/common/accessibility_snapshot.rbs
new file mode 100644
index 0000000000000..a6d5233866cf9
--- /dev/null
+++ b/rb/sig/lib/selenium/webdriver/common/accessibility_snapshot.rbs
@@ -0,0 +1,109 @@
+# 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.
+
+module Selenium
+ module WebDriver
+ class AccessibilityNode
+ type state = bool | Integer | String
+
+ type remote_value = Hash[String, untyped]
+
+ STATE_ORDER: Array[String]
+
+ attr_reader role: String
+
+ attr_reader name: String
+
+ attr_reader description: String
+
+ attr_reader value: String?
+
+ attr_reader states: Hash[String, state]
+
+ attr_reader element: Element?
+
+ attr_accessor children: Array[AccessibilityNode]
+
+ def initialize: (role: String, name: String, ?description: String, ?value: String?, ?states: Hash[String, state], ?element: Element?, ?children: Array[AccessibilityNode]) -> void
+
+ def to_text: () -> String
+
+ def self.from_wire: (Hash[String, untyped] data, Array[Element] elements) { (Integer frame) -> Array[AccessibilityNode] } -> AccessibilityNode
+
+ module Wire
+ def self?.object_fields: (AccessibilityNode::remote_value value) -> Hash[String, AccessibilityNode::remote_value]
+
+ def self?.string: (AccessibilityNode::remote_value value) -> String
+
+ def self?.shared_id: (AccessibilityNode::remote_value node) -> String
+
+ def self?.context_of: (AccessibilityNode::remote_value window) -> String?
+ end
+
+ def lines: (String indent) -> Array[String]
+
+ private
+
+ def line: () -> String
+
+ def state_tokens: () -> String
+
+ def quote: (String text) -> String
+
+ def one_line: (String text) -> String
+ end
+
+ class AccessibilitySnapshotter
+ include Atoms
+
+ SANDBOX: String
+
+ SERIALIZATION_OPTIONS: Hash[Symbol, Integer]
+
+ STALE_MESSAGE: String
+
+ class NotOwnedError < Error::WebDriverError
+ end
+
+ @bridge: Remote::Bridge
+
+ @bidi: BiDi
+
+ @atom: String?
+
+ def initialize: (Remote::Bridge bridge) -> void
+
+ def snapshot: (?Element? root) -> AccessibilityNode
+
+ private
+
+ def snapshot_context: (String context, String? root_id) -> AccessibilityNode?
+
+ def call: (String context, Array[Hash[Symbol, String]] arguments) -> AccessibilityNode::remote_value
+
+ def stitch: (String? context) -> Array[AccessibilityNode]
+
+ def element: (String shared_id) -> Element
+
+ def contexts: (String top) -> Array[String]
+
+ def frame_unreachable?: (Exception error) -> bool
+
+ def atom: () -> String
+ end
+ end
+end
diff --git a/rb/sig/lib/selenium/webdriver/common/driver.rbs b/rb/sig/lib/selenium/webdriver/common/driver.rbs
index 202af58ee23d2..89657fac5adca 100644
--- a/rb/sig/lib/selenium/webdriver/common/driver.rbs
+++ b/rb/sig/lib/selenium/webdriver/common/driver.rbs
@@ -37,6 +37,8 @@ module Selenium
def network: -> Network
+ def accessibility_snapshot: () -> AccessibilityNode
+
def status: () -> Hash[untyped, untyped]
def bidi: () -> BiDi
diff --git a/rb/sig/lib/selenium/webdriver/common/element.rbs b/rb/sig/lib/selenium/webdriver/common/element.rbs
index b94737c8415b9..a068c46cbbcd1 100644
--- a/rb/sig/lib/selenium/webdriver/common/element.rbs
+++ b/rb/sig/lib/selenium/webdriver/common/element.rbs
@@ -49,6 +49,8 @@ module Selenium
def accessible_name: () -> String
+ def accessibility_snapshot: () -> AccessibilityNode
+
def text: () -> String
def send_keys: (*untyped args) -> untyped
diff --git a/rb/sig/lib/selenium/webdriver/remote/bridge.rbs b/rb/sig/lib/selenium/webdriver/remote/bridge.rbs
index a145336e83043..73a66c8a5e6c0 100644
--- a/rb/sig/lib/selenium/webdriver/remote/bridge.rbs
+++ b/rb/sig/lib/selenium/webdriver/remote/bridge.rbs
@@ -42,6 +42,10 @@ module Selenium
PORT: Integer
+ def self.element_class: () -> singleton(Element)
+
+ def self.element_class=: (singleton(Element)? klass) -> void
+
attr_accessor http: untyped
attr_accessor file_detector: untyped
@@ -212,6 +216,8 @@ module Selenium
def element_aria_label: (untyped element) -> untyped
+ def accessibility_snapshot: (?Element? root) -> AccessibilityNode
+
def element_value: (untyped element) -> untyped
def element_text: (untyped element) -> untyped
diff --git a/rb/spec/integration/selenium/webdriver/bidi/accessibility_snapshot_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/accessibility_snapshot_spec.rb
new file mode 100644
index 0000000000000..20c23b0a5dc2d
--- /dev/null
+++ b/rb/spec/integration/selenium/webdriver/bidi/accessibility_snapshot_spec.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+# 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.
+
+require_relative '../spec_helper'
+require 'json'
+
+module Selenium
+ module WebDriver
+ # driver.accessibility_snapshot and element.accessibility_snapshot over BiDi (ADR 18205).
+ # 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.
+ describe AccessibilityNode, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do
+ # The atom records frames as leaves; the binding fills them in, so this page has its own tests.
+ let(:frames_page) { '014-iframe' }
+
+ def corpus(file)
+ path = File.expand_path("../../../../../../common/src/web/accessibility/#{file}", __dir__)
+ File.exist?(path) ? File.read(path, encoding: 'UTF-8') : nil
+ end
+
+ def engine
+ name = driver.capabilities.browser_name.to_s.downcase
+ return 'firefox' if name.include?('firefox')
+ return 'webkit' if name.include?('safari') || name.include?('webkit')
+
+ 'chromium'
+ end
+
+ # The engine-specific expectation when one is recorded, else the shared one.
+ def expected(page, suffix)
+ corpus("#{page}.expected.#{engine}.#{suffix}") || corpus("#{page}.expected.#{suffix}")
+ end
+
+ # The corpus JSON form: every field written, `element` as the element's id.
+ def serialize(node)
+ out = {'role' => node.role, 'name' => node.name, 'description' => node.description}
+ out['value'] = node.value unless node.value.nil?
+ out['states'] = node.states
+ out['element'] = node.element.attribute('id') unless node.element.nil?
+ out['children'] = node.children.map { |child| serialize(child) }
+ out
+ end
+
+ def walk(node, &block)
+ return enum_for(:walk, node) unless block
+
+ yield node
+ node.children.each { |child| walk(child, &block) }
+ end
+
+ def load_page(page)
+ driver.navigate.to url_for("accessibility/#{page}.html")
+ end
+
+ describe 'Driver#accessibility_snapshot' do
+ pages = File.expand_path('../../../../../../common/src/web/accessibility/index.json', __dir__)
+ JSON.parse(File.read(pages, encoding: 'UTF-8')).reject { |page| page == '014-iframe' }.each do |page|
+ it "matches the corpus for #{page}" do
+ load_page(page)
+
+ tree = driver.accessibility_snapshot
+
+ expect(serialize(tree)).to eq JSON.parse(expected(page, 'json'))
+ expect("#{tree.to_text}\n").to eq expected(page, 'txt')
+ end
+ end
+
+ it 'is rooted at the document' do
+ load_page('001-headings-and-landmarks')
+
+ tree = driver.accessibility_snapshot
+
+ expect(tree).to be_a described_class
+ expect(tree.role).to eq 'document'
+ expect(tree.element).to be_a Element
+ expect(tree.element.tag_name).to eq 'html'
+ end
+
+ it 'returns element references usable for further commands' do
+ load_page('003-links-and-buttons')
+
+ tree = driver.accessibility_snapshot
+
+ button = walk(tree).find { |node| node.role == 'button' }
+ expect(button).not_to be_nil
+ expect(button.element.tag_name).to eq('button').or eq('input').or eq('a')
+ expect(button.element.attribute('id')).not_to be_empty
+ end
+
+ it 'stitches frames in' do
+ load_page(frames_page)
+
+ tree = driver.accessibility_snapshot
+
+ frames = walk(tree).select { |node| node.role == 'iframe' }
+ expect(frames.map(&:name)).to eq ['Embedded content', '']
+ frames.each do |frame|
+ expect(frame.children.size).to eq(1), 'a reachable frame carries its document as the one child'
+ expect(frame.children.first.role).to eq 'document'
+ expect(frame.children.first.element).to be_a Element
+ end
+ # The text form renders the stitched tree; the atom-level expectation stops at the leaves.
+ expect(tree.to_text.scan('- document').size).to eq 3
+ # A reference inside a frame works through classic commands once that frame is current.
+ driver.switch_to.frame(frames.first.element)
+ begin
+ expect(frames.first.children.first.element.tag_name).to eq 'html'
+ ensure
+ driver.switch_to.default_content
+ end
+ end
+
+ it 'ignores the frame the driver switched to' do
+ load_page(frames_page)
+ driver.switch_to.frame('frame')
+ begin
+ tree = driver.accessibility_snapshot
+ ensure
+ driver.switch_to.default_content
+ end
+
+ expect(tree.name).to eq 'Frames'
+ end
+
+ it 'is a copy' do
+ load_page('005-lists')
+ before = driver.accessibility_snapshot
+ driver.execute_script("document.querySelector('li').textContent = 'changed'")
+
+ after = driver.accessibility_snapshot
+
+ expect(before.to_text).not_to eq after.to_text
+ expect(after.to_text).to include 'changed'
+ end
+
+ it 'needs a BiDi session' do
+ reset_driver!(web_socket_url: false) do |driver|
+ driver.navigate.to url_for('accessibility/005-lists.html')
+ expect { driver.accessibility_snapshot }
+ .to raise_error(Error::UnsupportedOperationError, /BiDi.*web_socket_url/)
+ expect { driver.find_element(tag_name: 'body').accessibility_snapshot }
+ .to raise_error(Error::UnsupportedOperationError, /BiDi.*web_socket_url/)
+ end
+ end
+ end
+
+ describe 'Element#accessibility_snapshot' do
+ it 'always produces a node for its root' do
+ load_page('005-lists')
+ root = driver.find_element(tag_name: 'body')
+
+ tree = root.accessibility_snapshot
+
+ expect(tree.role).to eq 'generic'
+ expect(tree.element.tag_name).to eq 'body'
+ expect(walk(tree).any? { |node| node.role == 'list' }).to be true
+ end
+
+ it 'snapshots a node from an earlier snapshot' do
+ load_page('005-lists')
+ first_list = walk(driver.accessibility_snapshot).find { |node| node.role == 'list' }
+
+ tree = first_list.element.accessibility_snapshot
+
+ expect(tree.role).to eq 'list'
+ expect(tree.children.map(&:role)).to eq first_list.children.map(&:role)
+ end
+
+ it 'carries the document of an iframe taken as the root' do
+ load_page(frames_page)
+ frame = driver.find_element(id: 'frame')
+
+ tree = frame.accessibility_snapshot
+
+ expect(tree.role).to eq 'iframe'
+ expect(tree.name).to eq 'Embedded content'
+ expect(tree.children.map(&:role)).to eq ['document']
+ end
+
+ it 'snapshots a subtree inside a frame' do
+ load_page(frames_page)
+ driver.switch_to.frame('frame')
+ begin
+ body = driver.find_element(tag_name: 'body')
+ tree = body.accessibility_snapshot
+ # The root is the frame's body, not the top document that owns the window.
+ expect(tree.role).to eq 'generic'
+ expect(tree.name).to eq ''
+ expect(tree.element.tag_name).to eq 'body'
+ ensure
+ driver.switch_to.default_content
+ end
+ end
+
+ it 'raises the stale element error for a stale root' do
+ load_page('005-lists')
+ root = driver.find_element(tag_name: 'body')
+ load_page('001-headings-and-landmarks')
+
+ expect { root.accessibility_snapshot }.to raise_error(Error::StaleElementReferenceError)
+ end
+
+ it 'raises the stale element error for a detached root' do
+ load_page('005-lists')
+ item = driver.find_element(tag_name: 'li')
+ driver.execute_script('arguments[0].remove()', item)
+
+ expect { item.accessibility_snapshot }.to raise_error(Error::StaleElementReferenceError)
+ end
+ end
+ end
+ end
+end
diff --git a/rb/spec/unit/BUILD.bazel b/rb/spec/unit/BUILD.bazel
index 9ab2916022fc2..3c867b2295f6f 100644
--- a/rb/spec/unit/BUILD.bazel
+++ b/rb/spec/unit/BUILD.bazel
@@ -19,6 +19,7 @@ rb_test(
size = "small",
srcs = glob(["**/*_spec.rb"]),
args = ["rb/spec/unit/"],
+ data = ["//common/src/web"],
main = "@bundle//bin:rspec",
tags = ["unit"],
visibility = ["//rb:__subpackages__"],
diff --git a/rb/spec/unit/selenium/webdriver/common/accessibility_snapshot_spec.rb b/rb/spec/unit/selenium/webdriver/common/accessibility_snapshot_spec.rb
new file mode 100644
index 0000000000000..f1696c330c6fb
--- /dev/null
+++ b/rb/spec/unit/selenium/webdriver/common/accessibility_snapshot_spec.rb
@@ -0,0 +1,248 @@
+# frozen_string_literal: true
+
+# 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.
+
+require File.expand_path('../spec_helper', __dir__)
+require 'json'
+
+module Selenium
+ module WebDriver
+ # The pure parts of the accessibility snapshot (ADR 18205): the text renderer is held
+ # to the shared corpus, and the decoder to the BiDi wire shape.
+ describe AccessibilityNode do
+ def corpus(file)
+ path = File.expand_path("../../../../../../common/src/web/accessibility/#{file}", __dir__)
+ File.read(path, encoding: 'UTF-8')
+ end
+
+ # The corpus JSON as nodes; `element` holds the recorded id, which the text form never renders.
+ def node_from_json(data)
+ described_class.new(
+ role: data['role'],
+ name: data['name'],
+ description: data['description'],
+ value: data['value'],
+ states: data['states'],
+ element: data['element'],
+ children: data['children'].map { |child| node_from_json(child) }
+ )
+ end
+
+ describe '#to_text' do
+ # Every expected.txt is the reference renderer's output over its expected.json; the port must agree.
+ pages = File.expand_path('../../../../../../common/src/web/accessibility/index.json', __dir__)
+ JSON.parse(File.read(pages, encoding: 'UTF-8')).each do |page|
+ it "renders #{page} as its recorded text" do
+ tree = node_from_json(JSON.parse(corpus("#{page}.expected.json")))
+
+ expect("#{tree.to_text}\n").to eq corpus("#{page}.expected.txt")
+ end
+ end
+
+ it 'writes the line grammar with its escapes' do
+ node = described_class.new(
+ role: 'textbox',
+ name: 'Say "hi" \\ bye',
+ states: {'focused' => true, 'disabled' => true, 'level' => 2, 'selected' => false, 'pressed' => 'mixed'},
+ value: "line one\nline two\\"
+ )
+
+ expected = '- textbox "Say \\"hi\\" \\\\ bye" [pressed=mixed] [level=2] [disabled] [focused]: ' \
+ 'line one\\nline two\\\\'
+ expect(node.to_text).to eq expected
+ expect(described_class.new(role: 'list', name: '').to_text).to eq '- list'
+ expect(described_class.new(role: 'text', name: 'plain words').to_text).to eq '- text: plain words'
+ expect(described_class.new(role: 'textbox', name: 'Empty', value: '').to_text).to eq '- textbox "Empty"'
+ end
+
+ it 'indents children two spaces per level' do
+ tree = described_class.new(
+ role: 'list',
+ name: '',
+ children: [
+ described_class.new(
+ role: 'listitem',
+ name: '',
+ children: [
+ described_class.new(role: 'text', name: 'one'),
+ described_class.new(role: 'link', name: 'more')
+ ]
+ )
+ ]
+ )
+
+ expect(tree.to_text).to eq "- list\n - listitem\n - text: one\n - link \"more\""
+ end
+ end
+
+ describe '.from_wire' do
+ # The atom's wire form: element indices into the elements list, a frame index for frame nodes.
+ it 'builds the tree and resolves the indices' do
+ data = {
+ 'role' => 'generic', 'name' => '', 'description' => '', 'states' => {}, 'element' => 0,
+ 'children' => [
+ {'role' => 'text', 'name' => 'Intro', 'description' => '', 'states' => {}, 'children' => []},
+ {'role' => 'textbox', 'name' => 'Email', 'description' => 'Where to write', 'value' => 'a@b.c',
+ 'states' => {'focused' => true, 'level' => 2, 'pressed' => 'mixed', 'selected' => false},
+ 'element' => 1, 'children' => []},
+ {'role' => 'iframe', 'name' => 'Embedded', 'description' => '', 'states' => {}, 'element' => 2,
+ 'frame' => 0, 'children' => []}
+ ]
+ }
+ stitched = []
+
+ node = described_class.from_wire(data, %w[element:0 element:1 element:2]) do |index|
+ stitched << index
+ [described_class.new(role: 'document', name: 'Inner')]
+ end
+
+ expect(node.role).to eq 'generic'
+ expect(node.element).to eq 'element:0'
+ text, textbox, frame = node.children
+ expect(text.role).to eq 'text'
+ expect(text.element).to be_nil
+ expect(text.children).to be_empty
+ expect(textbox.element).to eq 'element:1'
+ expect(textbox.value).to eq 'a@b.c'
+ expect(textbox.description).to eq 'Where to write'
+ expect(textbox.states).to eq('focused' => true, 'level' => 2, 'pressed' => 'mixed', 'selected' => false)
+ expect(frame.element).to eq 'element:2'
+ expect(frame.children.map(&:name)).to eq(['Inner']), "the frame's children come from the binding"
+ expect(stitched).to eq [0]
+ end
+
+ it 'keeps absent fields absent' do
+ node = described_class.from_wire({'role' => 'list', 'name' => '', 'states' => {}, 'children' => []}, []) do
+ []
+ end
+
+ expect(node.description).to eq ''
+ expect(node.value).to be_nil
+ expect(node.element).to be_nil
+ expect(node.children).to be_empty
+ end
+ end
+ end
+
+ describe Remote::Bridge, '#accessibility_snapshot' do
+ it 'needs a BiDi session' do
+ bridge = described_class.new(http_client: instance_double(Remote::Http::Default))
+
+ expect { bridge.accessibility_snapshot }
+ .to raise_error(Error::UnsupportedOperationError, /BiDi.*web_socket_url/)
+ end
+ end
+
+ describe AccessibilitySnapshotter do
+ # A stand-in for the BiDi connection of a window with one child frame: the top-level
+ # document answers "no such node" for the root, as Chromium does for a shared id another
+ # document owns, and the documents in `owners` answer with a generic node.
+ let(:connection) do
+ Class.new do
+ attr_reader :calls
+
+ def initialize(owners)
+ @owners = owners
+ @calls = []
+ end
+
+ def send_cmd(method, **params)
+ @calls << [method, params]
+ case method
+ when 'browsingContext.getTree'
+ {'contexts' => [{'context' => 'top', 'children' => [{'context' => 'child', 'children' => []}]}]}
+ when 'script.callFunction' then call_function(params)
+ end
+ end
+
+ private
+
+ def call_function(params)
+ raise Error::WebDriverError, 'no such node: unknown node' unless @owners.include?(params[:target][:context])
+
+ json = {'role' => 'generic', 'name' => '', 'description' => '', 'states' => {}, 'element' => 0,
+ 'children' => []}.to_json
+ {'type' => 'success',
+ 'result' => {'type' => 'object',
+ 'value' => [['json', {'type' => 'string', 'value' => json}],
+ ['elements',
+ {'type' => 'array', 'value' => [{'type' => 'node', 'sharedId' => 'shared-1'}]}],
+ ['windows', {'type' => 'array', 'value' => []}]]}}
+ end
+ end
+ end
+
+ def bridge_over(connection)
+ instance_double(Remote::BiDiBridge, bidi: connection, window_handle: 'top')
+ end
+
+ it 'tries each browsing context of the window until one owns the root' do
+ connection = self.connection.new(%w[child])
+ bridge = bridge_over(connection)
+
+ tree = described_class.new(bridge).snapshot(Element.new(bridge, 'shared-1'))
+
+ expect(tree.role).to eq 'generic'
+ calls = connection.calls.select { |call| call.first == 'script.callFunction' }
+ contexts = calls.map { |_, params| params[:target][:context] }
+ expect(contexts).to eq %w[top child]
+ end
+
+ it 'reads a root another document owns as nil and a detached root as the stale error' do
+ answers = []
+ connection = Class.new {
+ def initialize(answers)
+ @answers = answers
+ end
+
+ def send_cmd(_method, **_params)
+ {'type' => 'success', 'result' => @answers.shift}
+ end
+ }.new(answers)
+ snapshotter = described_class.new(bridge_over(connection))
+
+ answers << {'type' => 'null'}
+ expect(snapshotter.send(:snapshot_context, 'ctx', 'shared-1')).to be_nil
+
+ answers << {'type' => 'object', 'value' => [['error', {'type' => 'string', 'value' => 'detached'}]]}
+ expect { snapshotter.send(:snapshot_context, 'ctx', 'shared-1') }
+ .to raise_error(Error::StaleElementReferenceError)
+ end
+
+ it 'builds element references with the configured element class' do
+ custom = Class.new(Element)
+ Remote::Bridge.element_class = custom
+ bridge = bridge_over(connection.new(%w[top]))
+
+ tree = described_class.new(bridge).snapshot
+
+ expect(tree.element).to be_an_instance_of(custom)
+ expect(tree.element.ref).to eq [:element, 'shared-1']
+ ensure
+ Remote::Bridge.element_class = nil
+ end
+
+ it 'raises the stale element error when no context owns the root' do
+ bridge = bridge_over(connection.new([]))
+
+ expect { described_class.new(bridge).snapshot(Element.new(bridge, 'shared-1')) }
+ .to raise_error(Error::StaleElementReferenceError)
+ end
+ end
+ end
+end