feat(webkit): accessibility

This commit is contained in:
Joel Einbinder 2019-12-30 19:36:41 -08:00
parent ab226d0545
commit 7ef9f7d85c
6 changed files with 234 additions and 16 deletions

View file

@ -4,7 +4,7 @@ import * as dom from './dom';
export type SerializedAXNode = { export type SerializedAXNode = {
role: string, role: string,
name?: string, name: string,
value?: string|number, value?: string|number,
description?: string, description?: string,
@ -72,7 +72,7 @@ export class Accessibility {
const interestingNodes: Set<AXNode> = new Set(); const interestingNodes: Set<AXNode> = new Set();
collectInterestingNodes(interestingNodes, defaultRoot, false); collectInterestingNodes(interestingNodes, defaultRoot, false);
if (!interestingNodes.has(needle)) if (root && !interestingNodes.has(needle))
return null; return null;
return serializeTree(needle, interestingNodes)[0]; return serializeTree(needle, interestingNodes)[0];
} }

View file

@ -206,11 +206,11 @@ class CRAXNode implements accessibility.AXNode {
properties.set('description', this._payload.description.value); properties.set('description', this._payload.description.value);
const node: {[x in keyof accessibility.SerializedAXNode]: any} = { const node: {[x in keyof accessibility.SerializedAXNode]: any} = {
role: this._role role: this._role,
name: this._payload.name.value || ''
}; };
const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [ const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [
'name',
'value', 'value',
'description', 'description',
'keyshortcuts', 'keyshortcuts',

View file

@ -158,7 +158,8 @@ 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: this._role,
name: this._name || ''
}; };
const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [ const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [
'name', 'name',

View file

@ -0,0 +1,180 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as accessibility from '../accessibility';
import { WKTargetSession } from './wkConnection';
import { Protocol } from './protocol';
export async function getAccessibilityTree(sesssion: WKTargetSession) {
const {axNode} = await sesssion.send('Page.accessibilitySnapshot');
return new WKAXNode(axNode);
}
class WKAXNode implements accessibility.AXNode {
private _payload: Protocol.Page.AXNode;
private _children: WKAXNode[];
constructor(payload : Protocol.Page.AXNode) {
this._payload = payload;
this._children = [];
for (const payload of this._payload.children || [])
this._children.push(new WKAXNode(payload));
}
children() {
return this._children;
}
async findElement() {
return null;
}
isControl() : boolean {
switch (this._payload.role) {
case 'button':
case 'checkbox':
case 'ColorWell':
case 'combobox':
case 'DisclosureTriangle':
case 'listbox':
case 'menu':
case 'menubar':
case 'menuitem':
case 'menuitemcheckbox':
case 'menuitemradio':
case 'radio':
case 'scrollbar':
case 'searchbox':
case 'slider':
case 'spinbutton':
case 'switch':
case 'tab':
case 'textbox':
case 'TextField':
case 'tree':
return true;
default:
return false;
}
}
isInteresting(insideControl: boolean) : boolean {
const {role, focusable, name} = this._payload;
if (role === 'ScrollArea')
return false;
if (role === 'WebArea')
return true;
if (focusable || role === 'MenuListOption')
return true;
// If it's not focusable but has a control role, then it's interesting.
if (this.isControl())
return true;
// A non focusable child of a control is not interesting
if (insideControl)
return false;
return this.isLeafNode() && !!name;
}
isLeafNode() : boolean {
return !this._children.length;
}
serialize(): accessibility.SerializedAXNode {
const node : accessibility.SerializedAXNode = {
role: this._payload.role,
name: this._payload.name || '',
};
type AXPropertyOfType<Type> = {
[Key in keyof Protocol.Page.AXNode]:
Protocol.Page.AXNode[Key] extends Type ? Key : never
}[keyof Protocol.Page.AXNode];
const userStringProperties: AXPropertyOfType<string>[] = [
'value',
'description',
'keyshortcuts',
'roledescription',
'valuetext'
];
for (const userStringProperty of userStringProperties) {
if (!(userStringProperty in this._payload))
continue;
node[userStringProperty] = this._payload[userStringProperty];
}
const booleanProperties: AXPropertyOfType<boolean>[] = [
'disabled',
'expanded',
'focused',
'modal',
'multiline',
'multiselectable',
'readonly',
'required',
'selected',
];
for (const booleanProperty of booleanProperties) {
// WebArea and ScorllArea treat focus differently than other nodes. They report whether their frame has focus,
// not whether focus is specifically on the root node.
if (booleanProperty === 'focused' && (this._payload.role === 'WebArea' || this._payload.role === 'ScrollArea'))
continue;
const value = this._payload[booleanProperty];
if (!value)
continue;
node[booleanProperty] = value;
}
const tristateProperties: ('checked'|'pressed')[] = [
'checked',
'pressed',
];
for (const tristateProperty of tristateProperties) {
if (!(tristateProperty in this._payload))
continue;
const value = this._payload[tristateProperty];
node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
}
const numericalProperties: AXPropertyOfType<number>[] = [
'level',
'valuemax',
'valuemin',
];
for (const numericalProperty of numericalProperties) {
if (!(numericalProperty in this._payload))
continue;
node[numericalProperty] = this._payload[numericalProperty];
}
const tokenProperties: AXPropertyOfType<string>[] = [
'autocomplete',
'haspopup',
'invalid',
];
for (const tokenProperty of tokenProperties) {
const value = this._payload[tokenProperty];
if (!value || value === 'false')
continue;
node[tokenProperty] = value;
}
const orientationIsApplicable = new Set([
'ScrollArea',
'scrollbar',
'listbox',
'combobox',
'menu',
'tree',
'separator',
'slider',
'tablist',
'toolbar',
]);
if (this._payload.orientation && orientationIsApplicable.has(this._payload.role))
node.orientation = this._payload.orientation;
return node;
}
}

View file

@ -34,6 +34,7 @@ import * as types from '../types';
import * as jpeg from 'jpeg-js'; import * as jpeg from 'jpeg-js';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import * as accessibility from '../accessibility'; import * as accessibility from '../accessibility';
import { getAccessibilityTree } from './wkAccessibility';
const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const UTILITY_WORLD_NAME = '__playwright_utility_world__';
const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__';
@ -464,7 +465,7 @@ export class WKPage implements PageDelegate {
} }
async getAccessibilityTree() : Promise<accessibility.AXNode> { async getAccessibilityTree() : Promise<accessibility.AXNode> {
throw new Error('Not implemented'); return getAccessibilityTree(this._session);
} }
} }

View file

@ -20,7 +20,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const {it, fit, xit, dit} = testRunner; const {it, fit, xit, dit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
fdescribe('Accessibility', function() { describe('Accessibility', function() {
it('should work', async function({page}) { it('should work', async function({page}) {
await page.setContent(` await page.setContent(`
<head> <head>
@ -62,7 +62,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
{role: 'combobox option', name: 'First Option', selected: true}, {role: 'combobox option', name: 'First Option', selected: true},
{role: 'combobox option', name: 'Second Option'}] {role: 'combobox option', name: 'Second Option'}]
}] }]
} : { } : CHROME ? {
role: 'WebArea', role: 'WebArea',
name: 'Accessibility Test', name: 'Accessibility Test',
children: [ children: [
@ -79,6 +79,23 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
{role: 'menuitem', name: 'First Option', selected: true}, {role: 'menuitem', name: 'First Option', selected: true},
{role: 'menuitem', name: 'Second Option'}] {role: 'menuitem', name: 'Second Option'}]
}] }]
} : {
role: 'WebArea',
name: 'Accessibility Test',
children: [
{role: 'heading', name: 'Inputs', level: 1 },
{role: 'TextField', name: 'Empty input', focused: true, readonly: true},
{role: 'TextField', name: 'readonly input', readonly: true },
{role: 'TextField', name: 'disabled input', disabled: true, readonly: true},
{role: 'TextField', name: 'Input with whitespace', value: ' ', description: 'Input with whitespace', readonly: true},
{role: 'TextField', name: '', value: 'value only', readonly: true },
{role: 'TextField', name: 'placeholder',value: 'and a value',readonly: true},
{role: 'TextField', name: 'This is a description!',value: 'and a value',readonly: true},
{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);
}); });
@ -96,7 +113,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
role: 'text leaf', role: 'text leaf',
name: 'hi' name: 'hi'
}] }]
} : { } : CHROME ? {
role: 'textbox', role: 'textbox',
name: '', name: '',
value: 'hi', value: 'hi',
@ -109,10 +126,16 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
role: 'text', name: 'hi' role: 'text', name: 'hi'
}] }]
}] }]
} : {
role: 'textbox',
name: '',
value: 'hi',
focused: true,
multiline: true
}; };
expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual(golden); expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual(golden);
}); });
it('roledescription', async({page}) => { it.skip(WEBKIT)('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>');
const snapshot = await page.accessibility.snapshot(); const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].roledescription).toEqual('foo'); expect(snapshot.children[0].roledescription).toEqual('foo');
@ -122,7 +145,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, 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)('autocomplete', async({page}) => { it.skip(FFOX || WEBKIT)('autocomplete', async({page}) => {
await page.setContent('<input type="number" aria-autocomplete="list" />'); await page.setContent('<input type="number" aria-autocomplete="list" />');
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');
@ -169,7 +192,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
}; };
expect(await page.accessibility.snapshot()).toEqual(golden); expect(await page.accessibility.snapshot()).toEqual(golden);
}); });
it('rich text editable fields should have children', async function({page}) { // WebKit rich text accessibility is iffy
!WEBKIT && fit('rich text editable fields should have children', async function({page}) {
await page.setContent(` await page.setContent(`
<div contenteditable="true"> <div contenteditable="true">
Edit this image: <img src="fakeimage.png" alt="my fake image"> Edit this image: <img src="fakeimage.png" alt="my fake image">
@ -199,7 +223,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const snapshot = await page.accessibility.snapshot(); const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0]).toEqual(golden); expect(snapshot.children[0]).toEqual(golden);
}); });
it('rich text editable fields with role should have children', async function({page}) { // WebKit rich text accessibility is iffy
!WEBKIT && it('rich text editable fields with role should have children', async function({page}) {
await page.setContent(` await page.setContent(`
<div contenteditable="true" role='textbox'> <div contenteditable="true" role='textbox'>
Edit this image: <img src="fakeimage.png" alt="my fake image"> Edit this image: <img src="fakeimage.png" alt="my fake image">
@ -228,7 +253,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
expect(snapshot.children[0]).toEqual(golden); expect(snapshot.children[0]).toEqual(golden);
}); });
// Firefox does not support contenteditable="plaintext-only". // Firefox does not support contenteditable="plaintext-only".
!FFOX && describe('plaintext contenteditable', function() { // WebKit rich text accessibility is iffy
!FFOX && !WEBKIT && describe('plaintext contenteditable', function() {
it('plain text field with role should not have children', async function({page}) { it('plain text field with role should not have children', async function({page}) {
await page.setContent(` await page.setContent(`
<div contenteditable="plaintext-only" role='textbox'>Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`); <div contenteditable="plaintext-only" role='textbox'>Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`);
@ -268,10 +294,15 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
role: 'entry', role: 'entry',
name: 'my favorite textbox', name: 'my favorite textbox',
value: 'this is the inner content yo' value: 'this is the inner content yo'
} : { } : CHROME ? {
role: 'textbox', role: 'textbox',
name: 'my favorite textbox', name: 'my favorite textbox',
value: 'this is the inner content ' value: 'this is the inner content '
} : {
role: 'TextField',
name: 'my favorite textbox',
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);
@ -286,9 +317,14 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
role: 'checkbutton', role: 'checkbutton',
name: 'my favorite checkbox', name: 'my favorite checkbox',
checked: true checked: true
} : CHROME ? {
role: 'checkbox',
name: 'my favorite checkbox',
checked: true
} : { } : {
role: 'checkbox', role: 'checkbox',
name: 'my favorite checkbox', name: 'my favorite checkbox',
description: "my favorite checkbox",
checked: true checked: true
}; };
const snapshot = await page.accessibility.snapshot(); const snapshot = await page.accessibility.snapshot();
@ -313,7 +349,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
expect(snapshot.children[0]).toEqual(golden); expect(snapshot.children[0]).toEqual(golden);
}); });
describe.skip(FFOX)('root option', function() { describe.skip(FFOX || WEBKIT)('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>`);