feat(firefox&webkit): support root in accessibility.snapshot (#495)
This adds support for `root` in accessibility.snapshot firefox role names are now normalized to aria roles where they match webkit roledescriptions are less noisey on mac webkit mac/linux results are further defined interestingOnly tests are replaced by one that doesn't rely on undefined behavior the main accessibility test was split up a bit for more refined testing.
This commit is contained in:
parent
92bd854d8f
commit
aaa1c9203e
|
|
@ -55,13 +55,12 @@ export interface AXNode {
|
||||||
isLeafNode(): boolean;
|
isLeafNode(): boolean;
|
||||||
isControl(): boolean;
|
isControl(): boolean;
|
||||||
serialize(): SerializedAXNode;
|
serialize(): SerializedAXNode;
|
||||||
findElement(element: dom.ElementHandle): Promise<AXNode|null>;
|
|
||||||
children(): Iterable<AXNode>;
|
children(): Iterable<AXNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Accessibility {
|
export class Accessibility {
|
||||||
private _getAXTree: () => Promise<AXNode>;
|
private _getAXTree: (needle?: dom.ElementHandle) => Promise<{tree: AXNode, needle: AXNode | null}>;
|
||||||
constructor(getAXTree: () => Promise<AXNode>) {
|
constructor(getAXTree: (needle?: dom.ElementHandle) => Promise<{tree: AXNode, needle: AXNode | null}>) {
|
||||||
this._getAXTree = getAXTree;
|
this._getAXTree = getAXTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,21 +72,18 @@ export class Accessibility {
|
||||||
interestingOnly = true,
|
interestingOnly = true,
|
||||||
root = null,
|
root = null,
|
||||||
} = options;
|
} = options;
|
||||||
const defaultRoot = await this._getAXTree();
|
const {tree, needle} = await this._getAXTree(root || undefined);
|
||||||
let needle: AXNode | null = defaultRoot;
|
if (!interestingOnly) {
|
||||||
if (root) {
|
if (root)
|
||||||
needle = await defaultRoot.findElement(root);
|
return needle && serializeTree(needle)[0];
|
||||||
if (!needle)
|
return serializeTree(tree)[0];
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (!interestingOnly)
|
|
||||||
return serializeTree(needle)[0];
|
|
||||||
|
|
||||||
const interestingNodes: Set<AXNode> = new Set();
|
const interestingNodes: Set<AXNode> = new Set();
|
||||||
collectInterestingNodes(interestingNodes, defaultRoot, false);
|
collectInterestingNodes(interestingNodes, tree, false);
|
||||||
if (root && !interestingNodes.has(needle))
|
if (root && (!needle || !interestingNodes.has(needle)))
|
||||||
return null;
|
return null;
|
||||||
return serializeTree(needle, interestingNodes)[0];
|
return serializeTree(needle || tree, interestingNodes)[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,13 @@ import { Protocol } from './protocol';
|
||||||
import * as dom from '../dom';
|
import * as dom from '../dom';
|
||||||
import * as accessibility from '../accessibility';
|
import * as accessibility from '../accessibility';
|
||||||
|
|
||||||
export async function getAccessibilityTree(client: CRSession) : Promise<accessibility.AXNode> {
|
export async function getAccessibilityTree(client: CRSession, needle?: dom.ElementHandle) : Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> {
|
||||||
const {nodes} = await client.send('Accessibility.getFullAXTree');
|
const {nodes} = await client.send('Accessibility.getFullAXTree');
|
||||||
return CRAXNode.createTree(client, nodes);
|
const tree = CRAXNode.createTree(client, nodes);
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
needle: needle ? await tree._findElement(needle) : null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class CRAXNode implements accessibility.AXNode {
|
class CRAXNode implements accessibility.AXNode {
|
||||||
|
|
@ -90,7 +94,7 @@ class CRAXNode implements accessibility.AXNode {
|
||||||
return this._children;
|
return this._children;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findElement(element: dom.ElementHandle): Promise<CRAXNode | null> {
|
async _findElement(element: dom.ElementHandle): Promise<CRAXNode | null> {
|
||||||
const remoteObject = element._remoteObject as Protocol.Runtime.RemoteObject;
|
const remoteObject = element._remoteObject as Protocol.Runtime.RemoteObject;
|
||||||
const {node: {backendNodeId}} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId});
|
const {node: {backendNodeId}} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId});
|
||||||
const needle = this.find(node => node._payload.backendDOMNodeId === backendNodeId);
|
const needle = this.find(node => node._payload.backendDOMNodeId === backendNodeId);
|
||||||
|
|
|
||||||
|
|
@ -480,8 +480,8 @@ export class CRPage implements PageDelegate {
|
||||||
return to._createHandle(result.object).asElement()!;
|
return to._createHandle(result.object).asElement()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccessibilityTree(): Promise<accessibility.AXNode> {
|
async getAccessibilityTree(needle?: dom.ElementHandle) {
|
||||||
return getAccessibilityTree(this._client);
|
return getAccessibilityTree(this._client, needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pdf(options?: types.PDFOptions): Promise<platform.BufferType> {
|
async pdf(options?: types.PDFOptions): Promise<platform.BufferType> {
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,48 @@
|
||||||
import * as accessibility from '../accessibility';
|
import * as accessibility from '../accessibility';
|
||||||
import { FFSession } from './ffConnection';
|
import { FFSession } from './ffConnection';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
|
import * as dom from '../dom';
|
||||||
|
|
||||||
export async function getAccessibilityTree(session: FFSession) : Promise<accessibility.AXNode> {
|
export async function getAccessibilityTree(session: FFSession, needle?: dom.ElementHandle) : Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> {
|
||||||
const { tree } = await session.send('Accessibility.getFullAXTree');
|
const objectId = needle ? needle._remoteObject.objectId : undefined;
|
||||||
return new FFAXNode(tree);
|
const { tree } = await session.send('Accessibility.getFullAXTree', { objectId });
|
||||||
|
const axNode = new FFAXNode(tree);
|
||||||
|
return {
|
||||||
|
tree: axNode,
|
||||||
|
needle: needle ? axNode._findNeedle() : null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FFRoleToARIARole = new Map(Object.entries({
|
||||||
|
'pushbutton': 'button',
|
||||||
|
'checkbutton': 'checkbox',
|
||||||
|
'editcombobox': 'combobox',
|
||||||
|
'content deletion': 'deletion',
|
||||||
|
'footnote': 'doc-footnote',
|
||||||
|
'non-native document': 'document',
|
||||||
|
'grouping': 'group',
|
||||||
|
'graphic': 'img',
|
||||||
|
'content insertion': 'insertion',
|
||||||
|
'animation': 'marquee',
|
||||||
|
'flat equation': 'math',
|
||||||
|
'menupopup': 'menu',
|
||||||
|
'check menu item': 'menuitemcheckbox',
|
||||||
|
'radio menu item': 'menuitemradio',
|
||||||
|
'listbox option': 'option',
|
||||||
|
'radiobutton': 'radio',
|
||||||
|
'statusbar': 'status',
|
||||||
|
'pagetab': 'tab',
|
||||||
|
'pagetablist': 'tablist',
|
||||||
|
'propertypage': 'tabpanel',
|
||||||
|
'entry': 'textbox',
|
||||||
|
'outline': 'tree',
|
||||||
|
'tree table': 'treegrid',
|
||||||
|
'outlineitem': 'treeitem',
|
||||||
|
}));
|
||||||
|
|
||||||
class FFAXNode implements accessibility.AXNode {
|
class FFAXNode implements accessibility.AXNode {
|
||||||
_children: FFAXNode[];
|
_children: FFAXNode[];
|
||||||
private _payload: any;
|
private _payload: Protocol.Accessibility.AXTree;
|
||||||
private _editable: boolean;
|
private _editable: boolean;
|
||||||
private _richlyEditable: boolean;
|
private _richlyEditable: boolean;
|
||||||
private _focusable: boolean;
|
private _focusable: boolean;
|
||||||
|
|
@ -77,8 +110,15 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
return this._children;
|
return this._children;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findElement(): Promise<FFAXNode | null> {
|
_findNeedle(): FFAXNode | null {
|
||||||
throw new Error('Not implimented');
|
if (this._payload.foundObject)
|
||||||
|
return this;
|
||||||
|
for (const child of this._children) {
|
||||||
|
const found = child._findNeedle();
|
||||||
|
if (found)
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLeafNode(): boolean {
|
isLeafNode(): boolean {
|
||||||
|
|
@ -160,10 +200,10 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
|
|
||||||
serialize(): accessibility.SerializedAXNode {
|
serialize(): accessibility.SerializedAXNode {
|
||||||
const node: {[x in keyof accessibility.SerializedAXNode]: any} = {
|
const node: {[x in keyof accessibility.SerializedAXNode]: any} = {
|
||||||
role: this._role,
|
role: FFRoleToARIARole.get(this._role) || this._role,
|
||||||
name: this._name || ''
|
name: this._name || ''
|
||||||
};
|
};
|
||||||
const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [
|
const userStringProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Accessibility.AXTree> = [
|
||||||
'name',
|
'name',
|
||||||
'value',
|
'value',
|
||||||
'description',
|
'description',
|
||||||
|
|
@ -176,7 +216,7 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
continue;
|
continue;
|
||||||
node[userStringProperty] = this._payload[userStringProperty];
|
node[userStringProperty] = this._payload[userStringProperty];
|
||||||
}
|
}
|
||||||
const booleanProperties: Array<keyof accessibility.SerializedAXNode> = [
|
const booleanProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Accessibility.AXTree> = [
|
||||||
'disabled',
|
'disabled',
|
||||||
'expanded',
|
'expanded',
|
||||||
'focused',
|
'focused',
|
||||||
|
|
@ -195,7 +235,7 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
continue;
|
continue;
|
||||||
node[booleanProperty] = value;
|
node[booleanProperty] = value;
|
||||||
}
|
}
|
||||||
const tristateProperties: Array<keyof accessibility.SerializedAXNode> = [
|
const tristateProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Accessibility.AXTree> = [
|
||||||
'checked',
|
'checked',
|
||||||
'pressed',
|
'pressed',
|
||||||
];
|
];
|
||||||
|
|
@ -205,7 +245,7 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
const value = this._payload[tristateProperty];
|
const value = this._payload[tristateProperty];
|
||||||
node[tristateProperty] = value;
|
node[tristateProperty] = value;
|
||||||
}
|
}
|
||||||
const numericalProperties: Array<keyof accessibility.SerializedAXNode> = [
|
const numericalProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Accessibility.AXTree> = [
|
||||||
'level'
|
'level'
|
||||||
];
|
];
|
||||||
for (const numericalProperty of numericalProperties) {
|
for (const numericalProperty of numericalProperties) {
|
||||||
|
|
@ -213,7 +253,7 @@ class FFAXNode implements accessibility.AXNode {
|
||||||
continue;
|
continue;
|
||||||
node[numericalProperty] = this._payload[numericalProperty];
|
node[numericalProperty] = this._payload[numericalProperty];
|
||||||
}
|
}
|
||||||
const tokenProperties: Array<keyof accessibility.SerializedAXNode> = [
|
const tokenProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Accessibility.AXTree> = [
|
||||||
'autocomplete',
|
'autocomplete',
|
||||||
'haspopup',
|
'haspopup',
|
||||||
'invalid',
|
'invalid',
|
||||||
|
|
|
||||||
|
|
@ -355,8 +355,8 @@ export class FFPage implements PageDelegate {
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccessibilityTree() : Promise<accessibility.AXNode> {
|
async getAccessibilityTree(needle?: dom.ElementHandle) {
|
||||||
return getAccessibilityTree(this._session);
|
return getAccessibilityTree(this._session, needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
coverage(): Coverage | undefined {
|
coverage(): Coverage | undefined {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export interface PageDelegate {
|
||||||
setInputFiles(handle: dom.ElementHandle<HTMLInputElement>, files: types.FilePayload[]): Promise<void>;
|
setInputFiles(handle: dom.ElementHandle<HTMLInputElement>, files: types.FilePayload[]): Promise<void>;
|
||||||
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
|
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
|
||||||
|
|
||||||
getAccessibilityTree(): Promise<accessibility.AXNode>;
|
getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;
|
||||||
pdf?: (options?: types.PDFOptions) => Promise<platform.BufferType>;
|
pdf?: (options?: types.PDFOptions) => Promise<platform.BufferType>;
|
||||||
coverage(): Coverage | undefined;
|
coverage(): Coverage | undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,42 @@
|
||||||
import * as accessibility from '../accessibility';
|
import * as accessibility from '../accessibility';
|
||||||
import { WKSession } from './wkConnection';
|
import { WKSession } from './wkConnection';
|
||||||
import { Protocol } from './protocol';
|
import { Protocol } from './protocol';
|
||||||
|
import * as dom from '../dom';
|
||||||
|
|
||||||
export async function getAccessibilityTree(session: WKSession) {
|
export async function getAccessibilityTree(session: WKSession, needle?: dom.ElementHandle) {
|
||||||
const {axNode} = await session.send('Page.accessibilitySnapshot');
|
const objectId = needle ? needle._remoteObject.objectId : undefined;
|
||||||
return new WKAXNode(axNode);
|
const {axNode} = await session.send('Page.accessibilitySnapshot', { objectId });
|
||||||
|
const tree = new WKAXNode(axNode);
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
needle: needle ? tree._findNeedle() : null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WKRoleToARIARole = new Map(Object.entries({
|
||||||
|
'TextField': 'textbox',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// WebKit localizes role descriptions on mac, but the english versions only add noise.
|
||||||
|
const WKUnhelpfulRoleDescriptions = new Map(Object.entries({
|
||||||
|
'WebArea': 'HTML content',
|
||||||
|
'Summary': 'summary',
|
||||||
|
'DescriptionList': 'description list',
|
||||||
|
'ImageMap': 'image map',
|
||||||
|
'ListMarker': 'list marker',
|
||||||
|
'Video': 'video playback',
|
||||||
|
'Mark': 'highlighted',
|
||||||
|
'contentinfo': 'content information',
|
||||||
|
'Details': 'details',
|
||||||
|
'DescriptionListDetail': 'description',
|
||||||
|
'DescriptionListTerm': 'term',
|
||||||
|
'alertdialog': 'web alert dialog',
|
||||||
|
'dialog': 'web dialog',
|
||||||
|
'status': 'application status',
|
||||||
|
'tabpanel': 'tab panel',
|
||||||
|
'application': 'web application',
|
||||||
|
}));
|
||||||
|
|
||||||
class WKAXNode implements accessibility.AXNode {
|
class WKAXNode implements accessibility.AXNode {
|
||||||
private _payload: Protocol.Page.AXNode;
|
private _payload: Protocol.Page.AXNode;
|
||||||
private _children: WKAXNode[];
|
private _children: WKAXNode[];
|
||||||
|
|
@ -38,7 +68,14 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
return this._children;
|
return this._children;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findElement() {
|
_findNeedle() : WKAXNode | null {
|
||||||
|
if (this._payload.found)
|
||||||
|
return this;
|
||||||
|
for (const child of this._children) {
|
||||||
|
const found = child._findNeedle();
|
||||||
|
if (found)
|
||||||
|
return found;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,8 +108,26 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isTextControl() : boolean {
|
||||||
|
switch (this._payload.role) {
|
||||||
|
case 'combobox':
|
||||||
|
case 'searchfield':
|
||||||
|
case 'textbox':
|
||||||
|
case 'TextField':
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_name() : string {
|
||||||
|
if (this._payload.role === 'text')
|
||||||
|
return this._payload.value || '';
|
||||||
|
return this._payload.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
isInteresting(insideControl: boolean) : boolean {
|
isInteresting(insideControl: boolean) : boolean {
|
||||||
const {role, focusable, name} = this._payload;
|
const {role, focusable} = this._payload;
|
||||||
|
const name = this._name();
|
||||||
if (role === 'ScrollArea')
|
if (role === 'ScrollArea')
|
||||||
return false;
|
return false;
|
||||||
if (role === 'WebArea')
|
if (role === 'WebArea')
|
||||||
|
|
@ -92,30 +147,54 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
return this.isLeafNode() && !!name;
|
return this.isLeafNode() && !!name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_hasRendundantTextChild() {
|
||||||
|
if (this._children.length !== 1)
|
||||||
|
return false;
|
||||||
|
const child = this._children[0];
|
||||||
|
return child._payload.role === 'text' && this._payload.name === child._payload.value;
|
||||||
|
}
|
||||||
|
|
||||||
isLeafNode() : boolean {
|
isLeafNode() : boolean {
|
||||||
return !this._children.length;
|
if (!this._children.length)
|
||||||
|
return true;
|
||||||
|
// WebKit on Linux ignores everything inside text controls, normalize this behavior
|
||||||
|
if (this._isTextControl())
|
||||||
|
return true;
|
||||||
|
// WebKit for mac has text nodes inside heading, li, menuitem, a, and p nodes
|
||||||
|
if (this._hasRendundantTextChild())
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): accessibility.SerializedAXNode {
|
serialize(): accessibility.SerializedAXNode {
|
||||||
const node : accessibility.SerializedAXNode = {
|
const node : accessibility.SerializedAXNode = {
|
||||||
role: this._payload.role,
|
role: WKRoleToARIARole.get(this._payload.role) || this._payload.role,
|
||||||
name: this._payload.name || '',
|
name: this._name(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userStringProperties: string[] = [
|
if ('description' in this._payload && this._payload.description !== node.name)
|
||||||
'value',
|
node.description = this._payload.description;
|
||||||
'description',
|
|
||||||
|
if ('roledescription' in this._payload) {
|
||||||
|
const roledescription = this._payload.roledescription;
|
||||||
|
if (roledescription !== this._payload.role && WKUnhelpfulRoleDescriptions.get(this._payload.role) !== roledescription)
|
||||||
|
node.roledescription = roledescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('value' in this._payload && this._payload.role !== 'text')
|
||||||
|
node.value = this._payload.value;
|
||||||
|
|
||||||
|
const userStringProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Page.AXNode> = [
|
||||||
'keyshortcuts',
|
'keyshortcuts',
|
||||||
'roledescription',
|
|
||||||
'valuetext'
|
'valuetext'
|
||||||
];
|
];
|
||||||
for (const userStringProperty of userStringProperties) {
|
for (const userStringProperty of userStringProperties) {
|
||||||
if (!(userStringProperty in this._payload))
|
if (!(userStringProperty in this._payload))
|
||||||
continue;
|
continue;
|
||||||
(node as any)[userStringProperty] = (this._payload as any)[userStringProperty];
|
(node as any)[userStringProperty] = this._payload[userStringProperty];
|
||||||
}
|
}
|
||||||
|
|
||||||
const booleanProperties: string[] = [
|
const booleanProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Page.AXNode> = [
|
||||||
'disabled',
|
'disabled',
|
||||||
'expanded',
|
'expanded',
|
||||||
'focused',
|
'focused',
|
||||||
|
|
@ -131,7 +210,7 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
// not whether focus is specifically on the root node.
|
// not whether focus is specifically on the root node.
|
||||||
if (booleanProperty === 'focused' && (this._payload.role === 'WebArea' || this._payload.role === 'ScrollArea'))
|
if (booleanProperty === 'focused' && (this._payload.role === 'WebArea' || this._payload.role === 'ScrollArea'))
|
||||||
continue;
|
continue;
|
||||||
const value = (this._payload as any)[booleanProperty];
|
const value = this._payload[booleanProperty];
|
||||||
if (!value)
|
if (!value)
|
||||||
continue;
|
continue;
|
||||||
(node as any)[booleanProperty] = value;
|
(node as any)[booleanProperty] = value;
|
||||||
|
|
@ -147,7 +226,7 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
const value = this._payload[tristateProperty];
|
const value = this._payload[tristateProperty];
|
||||||
node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
|
node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
|
||||||
}
|
}
|
||||||
const numericalProperties: string[] = [
|
const numericalProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Page.AXNode> = [
|
||||||
'level',
|
'level',
|
||||||
'valuemax',
|
'valuemax',
|
||||||
'valuemin',
|
'valuemin',
|
||||||
|
|
@ -157,7 +236,7 @@ class WKAXNode implements accessibility.AXNode {
|
||||||
continue;
|
continue;
|
||||||
(node as any)[numericalProperty] = (this._payload as any)[numericalProperty];
|
(node as any)[numericalProperty] = (this._payload as any)[numericalProperty];
|
||||||
}
|
}
|
||||||
const tokenProperties: string[] = [
|
const tokenProperties: Array<keyof accessibility.SerializedAXNode & keyof Protocol.Page.AXNode> = [
|
||||||
'autocomplete',
|
'autocomplete',
|
||||||
'haspopup',
|
'haspopup',
|
||||||
'invalid',
|
'invalid',
|
||||||
|
|
|
||||||
|
|
@ -497,8 +497,8 @@ export class WKPage implements PageDelegate {
|
||||||
return to._createHandle(result.object) as dom.ElementHandle<T>;
|
return to._createHandle(result.object) as dom.ElementHandle<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccessibilityTree() : Promise<accessibility.AXNode> {
|
async getAccessibilityTree(needle?: dom.ElementHandle) : Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> {
|
||||||
return getAccessibilityTree(this._session);
|
return getAccessibilityTree(this._session, needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
coverage(): Coverage | undefined {
|
coverage(): Coverage | undefined {
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,18 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT}) {
|
module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT, MAC}) {
|
||||||
const {describe, xdescribe, fdescribe} = testRunner;
|
const {describe, xdescribe, fdescribe} = testRunner;
|
||||||
const {it, fit, xit, dit} = testRunner;
|
const {it, fit, xit, dit} = testRunner;
|
||||||
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
|
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
|
||||||
|
|
||||||
describe('Accessibility', function() {
|
describe('Accessibility', function() {
|
||||||
it.skip(WEBKIT)('should work', async function({page}) {
|
it('should work', async function({page}) {
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<head>
|
<head>
|
||||||
<title>Accessibility Test</title>
|
<title>Accessibility Test</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>Hello World</div>
|
|
||||||
<h1>Inputs</h1>
|
<h1>Inputs</h1>
|
||||||
<input placeholder="Empty input" autofocus />
|
<input placeholder="Empty input" autofocus />
|
||||||
<input placeholder="readonly input" readonly />
|
<input placeholder="readonly input" readonly />
|
||||||
|
|
@ -37,10 +36,6 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
<input aria-placeholder="placeholder" value="and a value" />
|
<input aria-placeholder="placeholder" value="and a value" />
|
||||||
<div aria-hidden="true" id="desc">This is a description!</div>
|
<div aria-hidden="true" id="desc">This is a description!</div>
|
||||||
<input aria-placeholder="placeholder" value="and a value" aria-describedby="desc" />
|
<input aria-placeholder="placeholder" value="and a value" aria-describedby="desc" />
|
||||||
<select>
|
|
||||||
<option>First Option</option>
|
|
||||||
<option>Second Option</option>
|
|
||||||
</select>
|
|
||||||
</body>`);
|
</body>`);
|
||||||
// autofocus happens after a delay in chrome these days
|
// autofocus happens after a delay in chrome these days
|
||||||
await page.waitForFunction(() => document.activeElement.hasAttribute('autofocus'));
|
await page.waitForFunction(() => document.activeElement.hasAttribute('autofocus'));
|
||||||
|
|
@ -49,24 +44,19 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
role: 'document',
|
role: 'document',
|
||||||
name: 'Accessibility Test',
|
name: 'Accessibility Test',
|
||||||
children: [
|
children: [
|
||||||
{role: 'text leaf', name: 'Hello World'},
|
|
||||||
{role: 'heading', name: 'Inputs', level: 1},
|
{role: 'heading', name: 'Inputs', level: 1},
|
||||||
{role: 'entry', name: 'Empty input', focused: true},
|
{role: 'textbox', name: 'Empty input', focused: true},
|
||||||
{role: 'entry', name: 'readonly input', readonly: true},
|
{role: 'textbox', name: 'readonly input', readonly: true},
|
||||||
{role: 'entry', name: 'disabled input', disabled: true},
|
{role: 'textbox', name: 'disabled input', disabled: true},
|
||||||
{role: 'entry', name: 'Input with whitespace', value: ' '},
|
{role: 'textbox', name: 'Input with whitespace', value: ' '},
|
||||||
{role: 'entry', name: '', value: 'value only'},
|
{role: 'textbox', name: '', value: 'value only'},
|
||||||
{role: 'entry', name: '', value: 'and a value'}, // firefox doesn't use aria-placeholder for the name
|
{role: 'textbox', name: '', value: 'and a value'}, // firefox doesn't use aria-placeholder for the name
|
||||||
{role: 'entry', name: '', value: 'and a value', description: 'This is a description!'}, // and here
|
{role: 'textbox', name: '', value: 'and a value', description: 'This is a description!'}, // and here
|
||||||
{role: 'combobox', name: '', value: 'First Option', haspopup: true, children: [
|
]
|
||||||
{role: 'combobox option', name: 'First Option', selected: true},
|
|
||||||
{role: 'combobox option', name: 'Second Option'}]
|
|
||||||
}]
|
|
||||||
} : CHROMIUM ? {
|
} : CHROMIUM ? {
|
||||||
role: 'WebArea',
|
role: 'WebArea',
|
||||||
name: 'Accessibility Test',
|
name: 'Accessibility Test',
|
||||||
children: [
|
children: [
|
||||||
{role: 'text', name: 'Hello World'},
|
|
||||||
{role: 'heading', name: 'Inputs', level: 1},
|
{role: 'heading', name: 'Inputs', level: 1},
|
||||||
{role: 'textbox', name: 'Empty input', focused: true},
|
{role: 'textbox', name: 'Empty input', focused: true},
|
||||||
{role: 'textbox', name: 'readonly input', readonly: true},
|
{role: 'textbox', name: 'readonly input', readonly: true},
|
||||||
|
|
@ -75,65 +65,30 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
{role: 'textbox', name: '', value: 'value only'},
|
{role: 'textbox', name: '', value: 'value only'},
|
||||||
{role: 'textbox', name: 'placeholder', value: 'and a value'},
|
{role: 'textbox', name: 'placeholder', value: 'and a value'},
|
||||||
{role: 'textbox', name: 'placeholder', value: 'and a value', description: 'This is a description!'},
|
{role: 'textbox', name: 'placeholder', value: 'and a value', description: 'This is a description!'},
|
||||||
{role: 'combobox', name: '', value: 'First Option', children: [
|
]
|
||||||
{role: 'menuitem', name: 'First Option', selected: true},
|
|
||||||
{role: 'menuitem', name: 'Second Option'}]
|
|
||||||
}]
|
|
||||||
} : {
|
} : {
|
||||||
role: 'WebArea',
|
role: 'WebArea',
|
||||||
name: 'Accessibility Test',
|
name: 'Accessibility Test',
|
||||||
children: [
|
children: [
|
||||||
{role: 'heading', name: 'Inputs', level: 1 },
|
{role: 'heading', name: 'Inputs', level: 1},
|
||||||
{role: 'TextField', name: 'Empty input', focused: true, readonly: true},
|
{role: 'textbox', name: 'Empty input', focused: true},
|
||||||
{role: 'TextField', name: 'readonly input', readonly: true },
|
{role: 'textbox', name: 'readonly input', readonly: true},
|
||||||
{role: 'TextField', name: 'disabled input', disabled: true, readonly: true},
|
{role: 'textbox', name: 'disabled input', disabled: true},
|
||||||
{role: 'TextField', name: 'Input with whitespace', value: ' ', description: 'Input with whitespace', readonly: true},
|
{role: 'textbox', name: 'Input with whitespace', value: ' ' },
|
||||||
{role: 'TextField', name: '', value: 'value only', readonly: true },
|
{role: 'textbox', name: '', value: 'value only' },
|
||||||
{role: 'TextField', name: 'placeholder',value: 'and a value',readonly: true},
|
{role: 'textbox', name: 'placeholder', value: 'and a value'},
|
||||||
{role: 'TextField', name: 'This is a description!',value: 'and a value',readonly: true},
|
{role: 'textbox', name: 'This is a description!',value: 'and a value'}, // webkit uses the description over placeholder for the name
|
||||||
{role: 'button', name: '', value: 'First Option', children: [
|
|
||||||
{ role: 'MenuListOption', name: '', value: 'First Option', selected: true },
|
|
||||||
{ role: 'MenuListOption', name: '', value: 'Second Option' }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
expect(await page.accessibility.snapshot()).toEqual(golden);
|
expect(await page.accessibility.snapshot()).toEqual(golden);
|
||||||
});
|
});
|
||||||
it.skip(WEBKIT)('should report uninteresting nodes', async function({page}) {
|
it.skip(WEBKIT && !MAC)('should work with regular text', async({page}) => {
|
||||||
await page.setContent(`<textarea autofocus>hi</textarea>`);
|
await page.setContent(`<div>Hello World</div>`);
|
||||||
// autofocus happens after a delay in chrome these days
|
const snapshot = await page.accessibility.snapshot();
|
||||||
await page.waitForFunction(() => document.activeElement.hasAttribute('autofocus'));
|
expect(snapshot.children[0]).toEqual({
|
||||||
const golden = FFOX ? {
|
role: FFOX ? 'text leaf' : 'text',
|
||||||
role: 'entry',
|
name: 'Hello World',
|
||||||
name: '',
|
});
|
||||||
value: 'hi',
|
|
||||||
focused: true,
|
|
||||||
multiline: true,
|
|
||||||
children: [{
|
|
||||||
role: 'text leaf',
|
|
||||||
name: 'hi'
|
|
||||||
}]
|
|
||||||
} : CHROMIUM ? {
|
|
||||||
role: 'textbox',
|
|
||||||
name: '',
|
|
||||||
value: 'hi',
|
|
||||||
focused: true,
|
|
||||||
multiline: true,
|
|
||||||
children: [{
|
|
||||||
role: 'generic',
|
|
||||||
name: '',
|
|
||||||
children: [{
|
|
||||||
role: 'text', name: 'hi'
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
} : {
|
|
||||||
role: 'textbox',
|
|
||||||
name: '',
|
|
||||||
value: 'hi',
|
|
||||||
focused: true,
|
|
||||||
multiline: true
|
|
||||||
};
|
|
||||||
expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual(golden);
|
|
||||||
});
|
});
|
||||||
it('roledescription', async({page}) => {
|
it('roledescription', async({page}) => {
|
||||||
await page.setContent('<div tabIndex=-1 aria-roledescription="foo">Hi</div>');
|
await page.setContent('<div tabIndex=-1 aria-roledescription="foo">Hi</div>');
|
||||||
|
|
@ -145,8 +100,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
const snapshot = await page.accessibility.snapshot();
|
const snapshot = await page.accessibility.snapshot();
|
||||||
expect(snapshot.children[0].orientation).toEqual('vertical');
|
expect(snapshot.children[0].orientation).toEqual('vertical');
|
||||||
});
|
});
|
||||||
it.skip(FFOX || WEBKIT)('autocomplete', async({page}) => {
|
it('autocomplete', async({page}) => {
|
||||||
await page.setContent('<input type="number" aria-autocomplete="list" />');
|
await page.setContent('<div role="textbox" aria-autocomplete="list">hi</div>');
|
||||||
const snapshot = await page.accessibility.snapshot();
|
const snapshot = await page.accessibility.snapshot();
|
||||||
expect(snapshot.children[0].autocomplete).toEqual('list');
|
expect(snapshot.children[0].autocomplete).toEqual('list');
|
||||||
});
|
});
|
||||||
|
|
@ -167,33 +122,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
<div role="tab" aria-selected="true"><b>Tab1</b></div>
|
<div role="tab" aria-selected="true"><b>Tab1</b></div>
|
||||||
<div role="tab">Tab2</div>
|
<div role="tab">Tab2</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
const golden = FFOX ? {
|
const golden = {
|
||||||
role: 'document',
|
role: FFOX ? 'document' : 'WebArea',
|
||||||
name: '',
|
|
||||||
children: [{
|
|
||||||
role: 'pagetab',
|
|
||||||
name: 'Tab1',
|
|
||||||
selected: true
|
|
||||||
}, {
|
|
||||||
role: 'pagetab',
|
|
||||||
name: 'Tab2'
|
|
||||||
}]
|
|
||||||
} : WEBKIT ? {
|
|
||||||
role: 'WebArea',
|
|
||||||
name: '',
|
|
||||||
roledescription: 'HTML content',
|
|
||||||
children: [{
|
|
||||||
role: 'tab',
|
|
||||||
name: 'Tab1',
|
|
||||||
roledescription: 'tab',
|
|
||||||
selected: true
|
|
||||||
}, {
|
|
||||||
role: 'tab',
|
|
||||||
name: 'Tab2',
|
|
||||||
roledescription: 'tab',
|
|
||||||
}]
|
|
||||||
} : {
|
|
||||||
role: 'WebArea',
|
|
||||||
name: '',
|
name: '',
|
||||||
children: [{
|
children: [{
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
|
|
@ -244,7 +174,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
Edit this image: <img src="fakeimage.png" alt="my fake image">
|
Edit this image: <img src="fakeimage.png" alt="my fake image">
|
||||||
</div>`);
|
</div>`);
|
||||||
const golden = FFOX ? {
|
const golden = FFOX ? {
|
||||||
role: 'entry',
|
role: 'textbox',
|
||||||
name: '',
|
name: '',
|
||||||
value: 'Edit this image: my fake image',
|
value: 'Edit this image: my fake image',
|
||||||
children: [{
|
children: [{
|
||||||
|
|
@ -305,7 +235,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
<img alt="yo" src="fakeimg.png">
|
<img alt="yo" src="fakeimg.png">
|
||||||
</div>`);
|
</div>`);
|
||||||
const golden = FFOX ? {
|
const golden = FFOX ? {
|
||||||
role: 'entry',
|
role: 'textbox',
|
||||||
name: 'my favorite textbox',
|
name: 'my favorite textbox',
|
||||||
value: 'this is the inner content yo'
|
value: 'this is the inner content yo'
|
||||||
} : CHROMIUM ? {
|
} : CHROMIUM ? {
|
||||||
|
|
@ -313,10 +243,9 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
name: 'my favorite textbox',
|
name: 'my favorite textbox',
|
||||||
value: 'this is the inner content '
|
value: 'this is the inner content '
|
||||||
} : {
|
} : {
|
||||||
role: 'TextField',
|
role: 'textbox',
|
||||||
name: 'my favorite textbox',
|
name: 'my favorite textbox',
|
||||||
value: 'this is the inner content ',
|
value: 'this is the inner content ',
|
||||||
description: 'my favorite textbox'
|
|
||||||
};
|
};
|
||||||
const snapshot = await page.accessibility.snapshot();
|
const snapshot = await page.accessibility.snapshot();
|
||||||
expect(snapshot.children[0]).toEqual(golden);
|
expect(snapshot.children[0]).toEqual(golden);
|
||||||
|
|
@ -327,19 +256,10 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
this is the inner content
|
this is the inner content
|
||||||
<img alt="yo" src="fakeimg.png">
|
<img alt="yo" src="fakeimg.png">
|
||||||
</div>`);
|
</div>`);
|
||||||
const golden = FFOX ? {
|
const golden = {
|
||||||
role: 'checkbutton',
|
|
||||||
name: 'my favorite checkbox',
|
|
||||||
checked: true
|
|
||||||
} : CHROMIUM ? {
|
|
||||||
role: 'checkbox',
|
role: 'checkbox',
|
||||||
name: 'my favorite checkbox',
|
name: 'my favorite checkbox',
|
||||||
checked: true
|
checked: true
|
||||||
} : {
|
|
||||||
role: 'checkbox',
|
|
||||||
name: 'my favorite checkbox',
|
|
||||||
description: "my favorite checkbox",
|
|
||||||
checked: true
|
|
||||||
};
|
};
|
||||||
const snapshot = await page.accessibility.snapshot();
|
const snapshot = await page.accessibility.snapshot();
|
||||||
expect(snapshot.children[0]).toEqual(golden);
|
expect(snapshot.children[0]).toEqual(golden);
|
||||||
|
|
@ -351,7 +271,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
<img alt="yo" src="fakeimg.png">
|
<img alt="yo" src="fakeimg.png">
|
||||||
</div>`);
|
</div>`);
|
||||||
const golden = FFOX ? {
|
const golden = FFOX ? {
|
||||||
role: 'checkbutton',
|
role: 'checkbox',
|
||||||
name: 'this is the inner content yo',
|
name: 'this is the inner content yo',
|
||||||
checked: true
|
checked: true
|
||||||
} : {
|
} : {
|
||||||
|
|
@ -363,7 +283,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
expect(snapshot.children[0]).toEqual(golden);
|
expect(snapshot.children[0]).toEqual(golden);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skip(FFOX || WEBKIT)('root option', function() {
|
describe('root option', function() {
|
||||||
it('should work a button', async({page}) => {
|
it('should work a button', async({page}) => {
|
||||||
await page.setContent(`<button>My Button</button>`);
|
await page.setContent(`<button>My Button</button>`);
|
||||||
|
|
||||||
|
|
@ -383,7 +303,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
value: 'My Value'
|
value: 'My Value'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should work a menu', async({page}) => {
|
it('should work on a menu', async({page}) => {
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<div role="menu" title="My Menu">
|
<div role="menu" title="My Menu">
|
||||||
<div role="menuitem">First Item</div>
|
<div role="menuitem">First Item</div>
|
||||||
|
|
@ -399,7 +319,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
children:
|
children:
|
||||||
[ { role: 'menuitem', name: 'First Item' },
|
[ { role: 'menuitem', name: 'First Item' },
|
||||||
{ role: 'menuitem', name: 'Second Item' },
|
{ role: 'menuitem', name: 'Second Item' },
|
||||||
{ role: 'menuitem', name: 'Third Item' } ]
|
{ role: 'menuitem', name: 'Third Item' } ],
|
||||||
|
orientation: WEBKIT ? 'vertical' : undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should return null when the element is no longer in DOM', async({page}) => {
|
it('should return null when the element is no longer in DOM', async({page}) => {
|
||||||
|
|
@ -408,38 +329,26 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT})
|
||||||
await page.$eval('button', button => button.remove());
|
await page.$eval('button', button => button.remove());
|
||||||
expect(await page.accessibility.snapshot({root: button})).toEqual(null);
|
expect(await page.accessibility.snapshot({root: button})).toEqual(null);
|
||||||
});
|
});
|
||||||
it('should support the interestingOnly option', async({page}) => {
|
it('should show uninteresting nodes', async({page}) => {
|
||||||
await page.setContent(`<div><button>My Button</button></div>`);
|
await page.setContent(`
|
||||||
const div = await page.$('div');
|
<div id="root" role="textbox">
|
||||||
expect(await page.accessibility.snapshot({root: div})).toEqual(null);
|
<div>
|
||||||
expect(await page.accessibility.snapshot({root: div, interestingOnly: false})).toEqual({
|
hello
|
||||||
role: 'generic',
|
<div>
|
||||||
name: '',
|
world
|
||||||
children: [
|
</div>
|
||||||
{
|
</div>
|
||||||
role: 'button',
|
</div>
|
||||||
name: 'My Button',
|
`);
|
||||||
children: [
|
|
||||||
{
|
const root = await page.$('#root');
|
||||||
role: "text",
|
const snapshot = await page.accessibility.snapshot({root, interestingOnly: false});
|
||||||
name: "My Button"
|
expect(snapshot.role).toBe('textbox');
|
||||||
}
|
expect(snapshot.value).toContain('hello');
|
||||||
],
|
expect(snapshot.value).toContain('world');
|
||||||
}
|
expect(!!snapshot.children).toBe(true);
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
function findFocusedNode(node) {
|
|
||||||
if (node.focused)
|
|
||||||
return node;
|
|
||||||
for (const child of node.children || []) {
|
|
||||||
const focusedChild = findFocusedNode(child);
|
|
||||||
if (focusedChild)
|
|
||||||
return focusedChild;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue