From 7ef9f7d85c0ae39096385747dc2716d6fe1b3edd Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Mon, 30 Dec 2019 19:36:41 -0800 Subject: [PATCH] feat(webkit): accessibility --- src/accessibility.ts | 4 +- src/chromium/crAccessibility.ts | 4 +- src/firefox/ffAccessibility.ts | 3 +- src/webkit/wkAccessibility.ts | 180 ++++++++++++++++++++++++++++++++ src/webkit/wkPage.ts | 3 +- test/accessibility.spec.js | 56 ++++++++-- 6 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 src/webkit/wkAccessibility.ts diff --git a/src/accessibility.ts b/src/accessibility.ts index 0cff258024..8673c94f1f 100644 --- a/src/accessibility.ts +++ b/src/accessibility.ts @@ -4,7 +4,7 @@ import * as dom from './dom'; export type SerializedAXNode = { role: string, - name?: string, + name: string, value?: string|number, description?: string, @@ -72,7 +72,7 @@ export class Accessibility { const interestingNodes: Set = new Set(); collectInterestingNodes(interestingNodes, defaultRoot, false); - if (!interestingNodes.has(needle)) + if (root && !interestingNodes.has(needle)) return null; return serializeTree(needle, interestingNodes)[0]; } diff --git a/src/chromium/crAccessibility.ts b/src/chromium/crAccessibility.ts index 45707fd12a..d1729193d2 100644 --- a/src/chromium/crAccessibility.ts +++ b/src/chromium/crAccessibility.ts @@ -206,11 +206,11 @@ class CRAXNode implements accessibility.AXNode { properties.set('description', this._payload.description.value); const node: {[x in keyof accessibility.SerializedAXNode]: any} = { - role: this._role + role: this._role, + name: this._payload.name.value || '' }; const userStringProperties: Array = [ - 'name', 'value', 'description', 'keyshortcuts', diff --git a/src/firefox/ffAccessibility.ts b/src/firefox/ffAccessibility.ts index 705e126b96..39b52d0f49 100644 --- a/src/firefox/ffAccessibility.ts +++ b/src/firefox/ffAccessibility.ts @@ -158,7 +158,8 @@ class FFAXNode implements accessibility.AXNode { serialize(): accessibility.SerializedAXNode { const node: {[x in keyof accessibility.SerializedAXNode]: any} = { - role: this._role + role: this._role, + name: this._name || '' }; const userStringProperties: Array = [ 'name', diff --git a/src/webkit/wkAccessibility.ts b/src/webkit/wkAccessibility.ts new file mode 100644 index 0000000000..570ea9cf80 --- /dev/null +++ b/src/webkit/wkAccessibility.ts @@ -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 = { + [Key in keyof Protocol.Page.AXNode]: + Protocol.Page.AXNode[Key] extends Type ? Key : never + }[keyof Protocol.Page.AXNode]; + + const userStringProperties: AXPropertyOfType[] = [ + 'value', + 'description', + 'keyshortcuts', + 'roledescription', + 'valuetext' + ]; + for (const userStringProperty of userStringProperties) { + if (!(userStringProperty in this._payload)) + continue; + node[userStringProperty] = this._payload[userStringProperty]; + } + + const booleanProperties: AXPropertyOfType[] = [ + '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[] = [ + 'level', + 'valuemax', + 'valuemin', + ]; + for (const numericalProperty of numericalProperties) { + if (!(numericalProperty in this._payload)) + continue; + node[numericalProperty] = this._payload[numericalProperty]; + } + const tokenProperties: AXPropertyOfType[] = [ + '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; + } +} diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index b3d0b12964..84a698cb8d 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -34,6 +34,7 @@ import * as types from '../types'; import * as jpeg from 'jpeg-js'; import { PNG } from 'pngjs'; import * as accessibility from '../accessibility'; +import { getAccessibilityTree } from './wkAccessibility'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; @@ -464,7 +465,7 @@ export class WKPage implements PageDelegate { } async getAccessibilityTree() : Promise { - throw new Error('Not implemented'); + return getAccessibilityTree(this._session); } } diff --git a/test/accessibility.spec.js b/test/accessibility.spec.js index 369e7db226..17923058b1 100644 --- a/test/accessibility.spec.js +++ b/test/accessibility.spec.js @@ -20,7 +20,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { const {it, fit, xit, dit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - fdescribe('Accessibility', function() { + describe('Accessibility', function() { it('should work', async function({page}) { await page.setContent(` @@ -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: 'Second Option'}] }] - } : { + } : CHROME ? { role: 'WebArea', name: 'Accessibility Test', children: [ @@ -79,6 +79,23 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { {role: 'menuitem', name: 'First Option', selected: true}, {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); }); @@ -96,7 +113,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { role: 'text leaf', name: 'hi' }] - } : { + } : CHROME ? { role: 'textbox', name: '', value: 'hi', @@ -109,10 +126,16 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { 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.skip(WEBKIT)('roledescription', async({page}) => { await page.setContent('
Hi
'); const snapshot = await page.accessibility.snapshot(); 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(); expect(snapshot.children[0].orientation).toEqual('vertical'); }); - it.skip(FFOX)('autocomplete', async({page}) => { + it.skip(FFOX || WEBKIT)('autocomplete', async({page}) => { await page.setContent(''); const snapshot = await page.accessibility.snapshot(); 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); }); - 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(`
Edit this image: my fake image @@ -199,7 +223,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { const snapshot = await page.accessibility.snapshot(); 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(`
Edit this image: my fake image @@ -228,7 +253,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { expect(snapshot.children[0]).toEqual(golden); }); // 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}) { await page.setContent(`
Edit this image:my fake image
`); @@ -268,10 +294,15 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { role: 'entry', name: 'my favorite textbox', value: 'this is the inner content yo' - } : { + } : CHROME ? { role: 'textbox', name: 'my favorite textbox', 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(); expect(snapshot.children[0]).toEqual(golden); @@ -286,9 +317,14 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { role: 'checkbutton', name: 'my favorite checkbox', checked: true + } : CHROME ? { + role: 'checkbox', + name: 'my favorite checkbox', + checked: true } : { role: 'checkbox', name: 'my favorite checkbox', + description: "my favorite checkbox", checked: true }; 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); }); - describe.skip(FFOX)('root option', function() { + describe.skip(FFOX || WEBKIT)('root option', function() { it('should work a button', async({page}) => { await page.setContent(``);