blob: 5ebf2cd10f26f9dc273876839a411dc90dbaee6f [file] [log] [blame] [edit]
function axDebug(msg)
{
getOrCreate("console", "div").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;
}
// 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);
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 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 AccessibilityUIElement
// passed in; before the operation is executed. The `operation` is expected to be a function,
// which can optionally be async.
async function waitForNotification(accessibilityElement, notificationName, operation) {
var reached = false;
function listener(receivedNotification) {
if (receivedNotification == notificationName) {
reached = true;
accessibilityElement.removeNotificationListener(listener);
}
}
accessibilityElement.addNotificationListener(listener);
await operation();
await waitFor(() => { return reached; });
}
// 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;
}