blob: 00157f406582536a476dfb0f85e5ee0c0c9e9dd4 [file] [log] [blame] [edit]
function axDebug(msg)
{
var log = document.getElementById("log");
if (!log)
log = document.getElementById("console");
if (!log) {
log = document.createElement("div");
log.id = "console";
document.body.insertBefore(log, document.body.firstChild);
}
log.innerText += `${msg}\n`;
}
// This function is necessary when printing AX attributes that are stringified with angle brackets:
// AXChildren: <array of size 0>
// `debug` outputs to the `innerHTML` of a generated element, so these brackets must be escaped to be printed.
function debugEscaped(message) {
debug(escapeHTML(message));
}
// Dumps the AX table by using the table cellForColumnAndRow API (which is what some ATs use).
function dumpAXTable(axElement, options) {
let output = "";
const rowCount = axElement.rowCount;
const columnCount = axElement.columnCount;
for (let row = 0; row < rowCount; row++) {
for (let column = 0; column < columnCount; column++)
output += `#${axElement.domIdentifier} cellForColumnAndRow(${column}, ${row}).domIdentifier is ${axElement.cellForColumnAndRow(column, row).domIdentifier}\n`;
}
return output;
}
// Dumps the result of a traversal via the UI element search API (which is what some ATs use).
// `options` is an object with these keys:
//
// * `excludeRoles`: Array of strings representing roles you don't want to include in the output.
// Case insensitive, partial match is fine, e.g. "scrollbar" will exclude "AXScrollBar".
//
// * `visibleOnly`: Specify true if only elements visible in the viewport should be returned.
//
// * `includeAccessibilityText`: Specify true if you want the accessibility text of each element to be included in the output.
function dumpAXSearchTraversal(axElement, options = { }) {
let output = "";
let searchResult = null;
const { visibleOnly = false } = options;
const { includeAccessibilityText = false } = options;
while (true) {
searchResult = axElement.uiElementForSearchPredicate(searchResult, /* directionIsNext */ true, "AXAnyTypeSearchKey", /* searchText */ "", visibleOnly);
if (!searchResult)
break;
const role = searchResult.role;
let excluded = false;
if (Array.isArray(options?.excludeRoles)) {
for (const excludedRole of options.excludeRoles) {
if (role.toLowerCase().includes(excludedRole.toLowerCase())) {
excluded = true;
break;
}
}
}
if (excluded)
continue;
const id = searchResult.domIdentifier;
let resultDescription = `${id ? `#${id} ` : ""}${role}`;
if (role.includes("StaticText"))
resultDescription += ` ${accessibilityController.platformName === "ios" ? searchResult.description : searchResult.stringValue}`;
if (includeAccessibilityText)
resultDescription += `\n${platformTextAlternatives(searchResult)}\n`;
output += `\n{${resultDescription}}\n`;
}
return output;
}
function platformStaticTextValue(axElement) {
if (!axElement)
return "";
if (!axElement.role.toLowerCase().includes("statictext"))
return `FAIL: platformStaticTextValue called on a non-text object (role was ${axElement.role}).\n`;
return accessibilityController.platformName === "ios" ? axElement.description : axElement.stringValue;
}
function stripAXPrefix(string) {
return string.replace(/^AX[A-Za-z]+:\s*/, "");
}
function expectStaticTextValue(axElement, expectedValue) {
if (!axElement)
return "";
if (!axElement.role.toLowerCase().includes("statictext"))
return `FAIL: platformStaticTextValue called on a non-text object (role was ${axElement.role}).\n`;
function pass(expected) {
return `PASS: Static text value was "${expected}"\n`;
}
function fail(expected, actual) {
return `FAIL: Static text value was not "${expected}" was ${stripAXPrefix(actual)}`;
}
var textValue;
if (accessibilityController.platformName === "ios") {
textValue = axElement.description;
if (textValue === `AXLabel: ${expectedValue}`)
return pass(expectedValue);
return fail(expectedValue, textValue)
}
textValue = axElement.stringValue;
if (textValue === `AXValue: ${expectedValue}`)
return pass(expectedValue);
return fail(expectedValue, textValue)
}
// Dumps the accessibility tree hierarchy for the given accessibilityObject into
// an element with id="tree", e.g., <pre id="tree"></pre>. In addition, it
// returns a two element array with the first element [0] being false if the
// traversal of the tree was stopped at the stopElement, and second element [1],
// the string representing the accessibility tree.
function dumpAccessibilityTree(accessibilityObject, stopElement, indent, allAttributesIfNeeded, getValueFromTitle, includeSubrole) {
var str = "";
var i = 0;
for (i = 0; i < indent; i++)
str += " ";
str += accessibilityObject.role;
if (includeSubrole === true && accessibilityObject.subrole)
str += " " + accessibilityObject.subrole;
str += " " + (getValueFromTitle === true ? accessibilityObject.title : accessibilityController.platformName === "ios" ? accessibilityObject.description : accessibilityObject.stringValue);
str += allAttributesIfNeeded && accessibilityObject.role == '' ? accessibilityObject.allAttributes() : '';
str += "\n";
var outputTree = document.getElementById("tree");
if (outputTree)
outputTree.innerText += str;
if (stopElement && stopElement.isEqual(accessibilityObject))
return [false, str];
var count = accessibilityObject.childrenCount;
for (i = 0; i < count; ++i) {
childRet = dumpAccessibilityTree(accessibilityObject.childAtIndex(i), stopElement, indent + 1, allAttributesIfNeeded, getValueFromTitle, includeSubrole);
if (!childRet[0])
return [false, str];
str += childRet[1];
}
return [true, str];
}
function touchAccessibilityTree(accessibilityObject) {
var count = accessibilityObject.childrenCount;
for (var i = 0; i < count; ++i) {
if (!touchAccessibilityTree(accessibilityObject.childAtIndex(i)))
return false;
}
return true;
}
function visibleRange(axElement, {width, height, scrollTop}) {
document.body.scrollTop = scrollTop;
testRunner.setViewSize(width, height);
return `Range with view ${width}x${height}, scrollTop ${scrollTop}: ${axElement.stringDescriptionOfAttributeValue("AXVisibleCharacterRange")}\n`;
}
async function verifyVisibleRange(axElement, {width, height, scrollTop}, allowedRangeStrings) {
testRunner.setViewSize(width, height);
document.body.scrollTop = scrollTop;
await waitFor(() => {
const visibleRange = axElement.stringDescriptionOfAttributeValue("AXVisibleCharacterRange");
for (let i = 0; i < allowedRangeStrings.length; i++) {
if (visibleRange.includes(allowedRangeStrings[i]))
return true;
}
return false;
});
return `Got expected visible-character-range for window width ${width}px, height ${height}px, scrollTop ${scrollTop}px\n`;
}
function platformValueForW3CName(accessibilityObject, includeSource=false) {
var result;
if (accessibilityController.platformName == "atspi")
result = accessibilityObject.title
else
result = accessibilityObject.description
if (!includeSource) {
var splitResult = result.split(": ");
return splitResult[1];
}
return result;
}
function platformValueForW3CDescription(accessibilityObject, includeSource=false) {
var result;
if (accessibilityController.platformName == "atspi")
result = accessibilityObject.description
else
result = accessibilityObject.helpText;
if (!includeSource) {
var splitResult = result.split(": ");
return splitResult[1];
}
return result;
}
function platformTextAlternatives(accessibilityObject, includeTitleUIElement=false) {
if (!accessibilityObject)
return "Element not exposed";
if (accessibilityController.platformName === "ios")
return `\t${accessibilityObject.description}`;
result = "\t" + accessibilityObject.title + "\n\t" + accessibilityObject.description;
if (accessibilityController.platformName == "mac")
result += "\n\t" + accessibilityObject.helpText;
if (includeTitleUIElement)
result += "\n\tAXTitleUIElement: " + (accessibilityObject.titleUIElement() ? "non-null" : "null");
return result;
}
function platformRoleForComboBox() {
return accessibilityController.platformName == "atspi" ? "AXRole: AXComboBox" : "AXRole: AXPopUpButton";
}
function platformRoleForStaticText() {
return accessibilityController.platformName == "atspi" ? "AXRole: AXStatic" : "AXRole: AXStaticText";
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function waitFor(condition)
{
return new Promise((resolve, reject) => {
// Schedule a timeout after 3 seconds if condition is never met.
let timeoutID = setTimeout(() => {
clearInterval(intervalID);
// Output a message to indicate that this call is timing out and avoid masking any possible failure.
let conditionString = condition.toString();
if (conditionString.length > 80)
conditionString = `${conditionString.substring(0, 80)}...`;
axDebug(`Condition '${conditionString}' was not satisfied in 3s, timing out.`);
resolve(false);
}, 3000);
// Repeatedly poll for the condition to be true.
let intervalID = setInterval(() => {
try {
if (condition()) {
clearTimeout(timeoutID);
clearInterval(intervalID);
resolve(true);
}
} catch (error) {
clearTimeout(timeoutID);
clearInterval(intervalID);
reject(error);
}
}, 0);
});
}
async function waitForFrameGeometryReady() {
await waitFor(() => {
return accessibilityController.rootElement.isFrameGeometryInitialized;
});
}
async function waitForElementById(id) {
let element;
await waitFor(() => {
element = accessibilityController.accessibleElementById(id);
return element;
});
return element;
}
// Executes the operation and waits until an accessibility notification of the provided
// `notificationName` is received. A notification listener is added to the passed
// AccessibilityUIElement if not null, or a global listener is installed, before
// the operation is executed. The `operation` is expected to be a function,
// which can optionally be async.
async function waitForNotification(axElement, notificationName, operation) {
var received = false;
function elementListener(notification) {
if (notification != notificationName)
return;
received = true;
axElement.removeNotificationListener(elementListener);
}
function globalListener(element, notification) {
if (notification != notificationName)
return;
received = true;
accessibilityController.removeNotificationListener(globalListener);
}
if (axElement)
axElement.addNotificationListener(elementListener);
else
accessibilityController.addNotificationListener(globalListener);
await operation();
await waitFor(() => { return received; });
}
// Executes the passed operation function and waits for expectedCount number of
// notifications of the given name. It takes a notificationHandler function to
// be executed when the proper notifications are received. Similarly to
// waitForNotification, the listener can be added to the given AccessibilityUIElement
// or globally.
async function waitForNotifications(axElement, notificationName, expectedCount, operation, notificationHandler) {
var receivedCount = 0;
function elementListener(notification) {
if (notification != notificationName)
return;
++receivedCount;
notificationHandler(axElement, notification);
if (receivedCount == expectedCount)
axElement.removeNotificationListener(elementListener);
}
function globalListener(element, notification) {
if (notification != notificationName)
return;
++receivedCount;
notificationHandler(element, notification);
if (receivedCount == expectedCount)
accessibilityController.removeNotificationListener(globalListener);
}
if (axElement)
axElement.addNotificationListener(elementListener);
else
accessibilityController.addNotificationListener(globalListener);
await operation();
await waitFor(() => { return receivedCount == expectedCount; });
}
// Expect an expression to equal a value and return the result as a string.
// This is essentially the more ubiquitous `shouldBe` function from js-test,
// but returns the result as a string rather than `debug`ing to a console DOM element.
function expect(expression, expectedValue) {
if (typeof expression !== "string")
debug("WARN: The expression arg in expect() should be a string.");
const evalExpression = `${expression} === ${expectedValue}`;
if (eval(evalExpression))
return `PASS: ${evalExpression}\n`;
return `FAIL: ${expression} !== ${expectedValue}, was ${eval(expression)}\n`;
}
function expectNumber(expression, expectedValue, allowedVariance = 0) {
if (typeof expression !== "string")
debug("WARN: The expression arg in expect() should be a string.");
const actualValue = eval(expression);
if (Math.abs(actualValue - expectedValue) <= allowedVariance)
return `PASS: ${expression} was ${allowedVariance === 0 ? "equal" : "equal or approximately equal"} to ${expectedValue}.\n`;
return `FAIL: ${expression} varied more than allowed variance ${allowedVariance}. Was: ${actualValue}, expected ${expectedValue}\n`;
}
function expectRectWithVariance(expression, x, y, width, height, allowedVariance) {
if (typeof expression !== "string")
debug("WARN: The expression arg in expectRectWithVariance() must be a string.");
if (typeof x !== "number" || typeof y !== "number" || typeof width !== "number" || typeof height !== "number" || typeof allowedVariance !== "number")
debug("WARN: The x, y, width, height, and allowedVariance arguments in expectRectWithVariance must be numbers.");
const expectedRect = `(x: ${x}, y: ${y}, w: ${width}, h: ${height})`;
const result = eval(expression);
const parsedResult = result
.replaceAll(/{|}/g, '') // Eliminate curly braces because they break parseFloat.
.split(/[ ,]+/) // Split on whitespace and commas.
.map(token => parseFloat(token))
.filter(float => !isNaN(float));
if (parsedResult.length !== 4)
debug(`FAIL: Expression ${expression} didn't produce a string result with four numbers (was ${result}).\n`);
else if (Math.abs(x - parsedResult[0]) > allowedVariance ||
Math.abs(y - parsedResult[1]) > allowedVariance ||
Math.abs(width - parsedResult[2]) > allowedVariance ||
Math.abs(height - parsedResult[3]) > allowedVariance)
return `FAIL: ${expression} varied more than allowed variance ${allowedVariance}. Was: ${result}, expected ${expectedRect}\n`;
else
return `PASS: ${expression} was ${allowedVariance === 0 ? "equal" : "equal or approximately equal"} to ${expectedRect}.\n`;
}
async function expectAsync(expression, expectedValue) {
if (typeof expression !== "string")
debug("WARN: The expression arg in expectAsync should be a string.");
const evalExpression = `${expression} === ${expectedValue}`;
return await waitFor(() => {
return eval(evalExpression);
}).then((success) => {
if (success) {
return `PASS: ${evalExpression}\n`;
} else {
return `FAIL: ${evalExpression}\n`;
}
}).catch((error) => {
return `FAIL: ${error}\n`;
});
}
async function expectAsyncExpression(expression, expectedValue) {
if (typeof expression !== "string")
debug("WARN: The expression arg in waitForExpression() should be a string.");
const evalExpression = `${expression} === ${expectedValue}`;
await waitFor(() => {
return eval(evalExpression);
}).then((success) => {
if (success) {
debug(`PASS ${evalExpression}`);
} else {
debug(`FAIL ${evalExpression}`);
}
}).catch((error) => {
debug(`FAIL ${error}`);
});
}
async function waitForFocus(id) {
document.getElementById(id).focus();
let focusedElement;
await waitFor(() => {
focusedElement = accessibilityController.focusedElement;
return focusedElement && focusedElement.domIdentifier === id;
});
return focusedElement;
}
// Selects text within the element with the given ID, and returns the global selected `TextMarkerRange` after doing so.
// Optionally takes the AccessibilityUIElement associated with the web area as an argument. If not passed, we'll
// retrieve it in the function.
// NOTE: Only available for WK2.
async function selectElementTextById(id, axWebArea) {
const webArea = axWebArea ? axWebArea : accessibilityController.rootElement.childAtIndex(0);
const selection = window.getSelection();
selection.removeAllRanges();
await waitFor(() => {
const selectedRange = webArea.selectedTextMarkerRange();
return !webArea.isTextMarkerRangeValid(selectedRange);
});
const range = document.createRange();
range.selectNodeContents(document.getElementById(id));
selection.addRange(range);
let selectedRange;
await waitFor(() => {
selectedRange = webArea.selectedTextMarkerRange();
return webArea.isTextMarkerRangeValid(selectedRange);
});
return selectedRange;
}
// Selects a range of text delimited by the `startIndex` and `endIndex` within the element with the given ID,
// and returns the global selected `TextMarkerRange` after doing so. Optionally takes the AccessibilityUIElement
// associated with the web area as an argument. If not passed, we'll retrieve it in the function.
// NOTE: Only available for WK2.
async function selectPartialElementTextById(id, startIndex, endIndex, axWebArea) {
const webArea = axWebArea ? axWebArea : accessibilityController.rootElement.childAtIndex(0);
const selection = window.getSelection();
selection.removeAllRanges();
await waitFor(() => {
const selectedRange = webArea.selectedTextMarkerRange();
return !webArea.isTextMarkerRangeValid(selectedRange);
});
const element = document.getElementById(id);
if (element.setSelectionRange) {
// For textarea and input elements.
element.focus();
element.setSelectionRange(startIndex, endIndex);
} else {
// For contenteditable elements.
const range = document.createRange();
range.setStart(element.firstChild, startIndex);
range.setEnd(element.firstChild, endIndex);
selection.addRange(range);
}
let selectedRange;
await waitFor(() => {
selectedRange = webArea.selectedTextMarkerRange();
return webArea.isTextMarkerRangeValid(selectedRange);
});
return selectedRange;
}
function evalAndReturn(expression) {
if (typeof expression !== "string")
debug("FAIL: evalAndReturn() expects a string argument");
eval(expression);
return `${expression}\n`;
}
function outputSelectedChildren(axElement) {
const children = axElement.selectedChildren();
output += " selectedChildren: [ ";
for (let i = 0; i < children.length; ++i) {
if (i > 0)
output += ", ";
output += children[i].domIdentifier;
}
output += " ]\n";
}
function resetActiveElementAndSelectedChildren(id) {
Array.from(document.getElementById(id).children).forEach((child) => {
child.removeAttribute("aria-activedescendant");
child.removeAttribute("aria-selected");
});
}
function findFirstPageDescendant(startObject) {
if (!startObject || startObject.role == "AXRole: AXPage")
return startObject;
for (let i = 0; i < startObject.childrenCount; i++) {
let result = findFirstPageDescendant(startObject.childAtIndex(i));
if (result)
return result;
}
return null;
}
function traverseChildrenToFirstStaticText(startObject) {
if (!startObject || startObject.role == "AXRole: AXStaticText")
return startObject;
for (let i = 0; i < startObject.childrenCount; i++) {
let result = traverseChildrenToFirstStaticText(startObject.childAtIndex(i));
if (result)
return result;
}
return null;
}
function formatAriaNotifyUserInfo(userInfo) {
var result = "";
result += `AnnouncementKey: ${userInfo["AXAnnouncementKey"]}\n`;
result += `AXARIAAnnouncementInterruptBehavior: ${userInfo["AXARIAAnnouncementInterruptBehavior"]}\n`;
result += `AXARIAAnnouncementPriority: ${userInfo["AXARIAAnnouncementPriority"]}\n`;
result += `AXAnnouncementLanguageKey: ${userInfo["AXAnnouncementLanguageKey"]}\n\n`
return result;
}
function formatAnnouncementUserInfo(userInfo) {
var result = "";
result += `AnnouncementKey: ${userInfo["AXAnnouncementKey"]}\n`;
result += `AXPriorityKey: ${userInfo["AXPriorityKey"]}\n`;
result += `AXAnnouncementIsLiveRegionKey: ${userInfo["AXAnnouncementIsLiveRegionKey"]}\n`;
return result;
}
// Checks that text alternatives include all expected values and exclude all unexpected values.
// Returns a string with PASS/FAIL results for each check.
function checkTextAlternatives(axElement, { expected = [], unexpected = [] }) {
var alternatives = platformTextAlternatives(axElement);
var result = "";
for (var i = 0; i < expected.length; i++) {
if (alternatives.includes(expected[i]))
result += `PASS: Text alternatives include '${expected[i]}'\n`;
else
result += `FAIL: Text alternatives should include '${expected[i]}' but got:\n${alternatives}\n`;
}
for (var i = 0; i < unexpected.length; i++) {
if (!alternatives.includes(unexpected[i]))
result += `PASS: Text alternatives do not include '${unexpected[i]}'\n`;
else
result += `FAIL: Text alternatives should NOT include '${unexpected[i]}' but got:\n${alternatives}\n`;
}
return result;
}
// Keep walking the tree, calling the given elementTests on each element, until each returns true on at least one element.
function waitForElements(elementTests) {
// Recursively searches all elements from element returns true if elementTests each pass on
// at least one element. The passes array is used to keep track of which tests have passed
// so far and doesn't need to be passed in.
function checkElementTests(element, elementTests, passes) {
if (!element || !element.role) {
return false;
}
if (passes === undefined) {
passes = Array(elementTests.length).fill(false);
}
for (let i = 0; i < elementTests.length; i++) {
if (!passes[i] && elementTests[i](element)) {
passes[i] = true;
}
}
const childrenCount = element.childrenCount;
for (let i = 0; i < childrenCount; i++) {
checkElementTests(element.childAtIndex(i), elementTests, passes);
}
// Return if all tests passed
return passes.every(Boolean);
}
return new Promise((resolve, reject) => {
// Schedule a timeout after 3 seconds if condition is never met.
let timeoutID = setTimeout(() => {
clearInterval(intervalID);
reject("Timed out");
}, 3000);
// Repeatedly poll until all elementTests pass or we time out.
let intervalID = setInterval(() => {
try {
let root = accessibilityController.rootElement;
if (checkElementTests(root, elementTests)) {
clearTimeout(timeoutID);
clearInterval(intervalID);
resolve(true);
}
} catch (error) {
clearTimeout(timeoutID);
clearInterval(intervalID);
reject(error);
}
}, 0);
});
}