feat(firefox): page.accessibility.snapshot() (#4071)

This commit is contained in:
Joel Einbinder 2019-02-25 21:57:33 -08:00 committed by Andrey Lushnikov
parent f21486fa1b
commit 03d06f54d6
6 changed files with 486 additions and 54 deletions

View File

@ -0,0 +1,322 @@
/**
* @typedef {Object} SerializedAXNode
* @property {string} role
*
* @property {string=} name
* @property {string|number=} value
* @property {string=} description
*
* @property {string=} keyshortcuts
* @property {string=} roledescription
* @property {string=} valuetext
*
* @property {boolean=} disabled
* @property {boolean=} expanded
* @property {boolean=} focused
* @property {boolean=} modal
* @property {boolean=} multiline
* @property {boolean=} multiselectable
* @property {boolean=} readonly
* @property {boolean=} required
* @property {boolean=} selected
*
* @property {boolean|"mixed"=} checked
* @property {boolean|"mixed"=} pressed
*
* @property {number=} level
*
* @property {string=} autocomplete
* @property {string=} haspopup
* @property {string=} invalid
* @property {string=} orientation
*
* @property {Array<SerializedAXNode>=} children
*/
class Accessibility {
constructor(session) {
this._session = session;
}
/**
* @param {{interestingOnly?: boolean}=} options
* @return {!Promise<!SerializedAXNode>}
*/
async snapshot(options = {}) {
const {interestingOnly = true} = options;
const {tree} = await this._session.send('Accessibility.getFullAXTree');
const root = new AXNode(tree);
if (!interestingOnly)
return serializeTree(root)[0];
/** @type {!Set<!AXNode>} */
const interestingNodes = new Set();
collectInterestingNodes(interestingNodes, root, false);
return serializeTree(root, interestingNodes)[0];
}
}
/**
* @param {!Set<!AXNode>} collection
* @param {!AXNode} node
* @param {boolean} insideControl
*/
function collectInterestingNodes(collection, node, insideControl) {
if (node.isInteresting(insideControl))
collection.add(node);
if (node.isLeafNode())
return;
insideControl = insideControl || node.isControl();
for (const child of node._children)
collectInterestingNodes(collection, child, insideControl);
}
/**
* @param {!AXNode} node
* @param {!Set<!AXNode>=} whitelistedNodes
* @return {!Array<!SerializedAXNode>}
*/
function serializeTree(node, whitelistedNodes) {
/** @type {!Array<!SerializedAXNode>} */
const children = [];
for (const child of node._children)
children.push(...serializeTree(child, whitelistedNodes));
if (whitelistedNodes && !whitelistedNodes.has(node))
return children;
const serializedNode = node.serialize();
if (children.length)
serializedNode.children = children;
return [serializedNode];
}
class AXNode {
constructor(payload) {
this._payload = payload;
/** @type {!Array<!AXNode>} */
this._children = (payload.children || []).map(x => new AXNode(x));
this._editable = payload.editable;
this._richlyEditable = this._editable && (payload.tag !== 'textarea' && payload.tag !== 'input');
this._focusable = payload.focusable;
this._expanded = payload.expanded;
this._name = this._payload.name;
this._role = this._payload.role;
this._cachedHasFocusableChild;
}
/**
* @return {boolean}
*/
_isPlainTextField() {
if (this._richlyEditable)
return false;
if (this._editable)
return true;
return this._role === 'entry';
}
/**
* @return {boolean}
*/
_isTextOnlyObject() {
const role = this._role;
return (role === 'text leaf' || role === 'text' || role === 'statictext');
}
/**
* @return {boolean}
*/
_hasFocusableChild() {
if (this._cachedHasFocusableChild === undefined) {
this._cachedHasFocusableChild = false;
for (const child of this._children) {
if (child._focusable || child._hasFocusableChild()) {
this._cachedHasFocusableChild = true;
break;
}
}
}
return this._cachedHasFocusableChild;
}
/**
* @return {boolean}
*/
isLeafNode() {
if (!this._children.length)
return true;
// These types of objects may have children that we use as internal
// implementation details, but we want to expose them as leaves to platform
// accessibility APIs because screen readers might be confused if they find
// any children.
if (this._isPlainTextField() || this._isTextOnlyObject())
return true;
// Roles whose children are only presentational according to the ARIA and
// HTML5 Specs should be hidden from screen readers.
// (Note that whilst ARIA buttons can have only presentational children, HTML5
// buttons are allowed to have content.)
switch (this._role) {
case 'graphic':
case 'scrollbar':
case 'slider':
case 'separator':
case 'progressbar':
return true;
default:
break;
}
// Here and below: Android heuristics
if (this._hasFocusableChild())
return false;
if (this._focusable && this._name)
return true;
if (this._role === 'heading' && this._name)
return true;
return false;
}
/**
* @return {boolean}
*/
isControl() {
switch (this._role) {
case 'checkbutton':
case 'check menu item':
case 'check rich option':
case 'combobox':
case 'combobox option':
case 'color chooser':
case 'listbox':
case 'listbox option':
case 'listbox rich option':
case 'popup menu':
case 'menupopup':
case 'menuitem':
case 'menubar':
case 'button':
case 'pushbutton':
case 'radiobutton':
case 'radio menuitem':
case 'scrollbar':
case 'slider':
case 'spinbutton':
case 'switch':
case 'pagetab':
case 'entry':
case 'tree table':
return true;
default:
return false;
}
}
/**
* @param {boolean} insideControl
* @return {boolean}
*/
isInteresting(insideControl) {
if (this._focusable || this._richlyEditable)
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() && !!this._name.trim();
}
/**
* @return {!SerializedAXNode}
*/
serialize() {
/** @type {SerializedAXNode} */
const node = {
role: this._role
};
/** @type {!Array<keyof SerializedAXNode>} */
const userStringProperties = [
'name',
'value',
'description',
'roledescription',
'valuetext',
'keyshortcuts',
];
for (const userStringProperty of userStringProperties) {
if (!(userStringProperty in this._payload))
continue;
node[userStringProperty] = this._payload[userStringProperty];
}
/** @type {!Array<keyof SerializedAXNode>} */
const booleanProperties = [
'disabled',
'expanded',
'focused',
'modal',
'multiline',
'multiselectable',
'readonly',
'required',
'selected',
];
for (const booleanProperty of booleanProperties) {
if (this._role === 'document' && booleanProperty === 'focused')
continue; // document focusing is strange
const value = this._payload[booleanProperty];
if (!value)
continue;
node[booleanProperty] = value;
}
/** @type {!Array<keyof SerializedAXNode>} */
const tristateProperties = [
'checked',
'pressed',
];
for (const tristateProperty of tristateProperties) {
if (!(tristateProperty in this._payload))
continue;
const value = this._payload[tristateProperty];
node[tristateProperty] = value;
}
/** @type {!Array<keyof SerializedAXNode>} */
const numericalProperties = [
'level',
'valuemax',
'valuemin',
];
for (const numericalProperty of numericalProperties) {
if (!(numericalProperty in this._payload))
continue;
node[numericalProperty] = this._payload[numericalProperty];
}
/** @type {!Array<keyof SerializedAXNode>} */
const tokenProperties = [
'autocomplete',
'haspopup',
'invalid',
'orientation',
];
for (const tokenProperty of tokenProperties) {
const value = this._payload[tokenProperty];
if (!value || value === 'false')
continue;
node[tokenProperty] = value;
}
return node;
}
}
module.exports = {Accessibility};

View File

@ -11,7 +11,8 @@ const {Events} = require('./Events');
const {FrameManager, normalizeWaitUntil} = require('./FrameManager'); const {FrameManager, normalizeWaitUntil} = require('./FrameManager');
const {NetworkManager} = require('./NetworkManager'); const {NetworkManager} = require('./NetworkManager');
const {TimeoutSettings} = require('./TimeoutSettings'); const {TimeoutSettings} = require('./TimeoutSettings');
const {NavigationWatchdog, NextNavigationWatchdog} = require('./NavigationWatchdog'); const {NavigationWatchdog} = require('./NavigationWatchdog');
const {Accessibility} = require('./Accessibility');
const writeFileAsync = util.promisify(fs.writeFile); const writeFileAsync = util.promisify(fs.writeFile);
@ -47,6 +48,7 @@ class Page extends EventEmitter {
this._keyboard = new Keyboard(session); this._keyboard = new Keyboard(session);
this._mouse = new Mouse(session, this._keyboard); this._mouse = new Mouse(session, this._keyboard);
this._touchscreen = new Touchscreen(session, this._keyboard, this._mouse); this._touchscreen = new Touchscreen(session, this._keyboard, this._mouse);
this._accessibility = new Accessibility(session);
this._closed = false; this._closed = false;
/** @type {!Map<string, Function>} */ /** @type {!Map<string, Function>} */
this._pageBindings = new Map(); this._pageBindings = new Map();
@ -266,7 +268,7 @@ class Page extends EventEmitter {
} }
_onUncaughtError(params) { _onUncaughtError(params) {
let error = new Error(params.message); const error = new Error(params.message);
error.stack = params.stack; error.stack = params.stack;
this.emit(Events.Page.PageError, error); this.emit(Events.Page.PageError, error);
} }
@ -330,6 +332,10 @@ class Page extends EventEmitter {
return this._frameManager.mainFrame(); return this._frameManager.mainFrame();
} }
get accessibility() {
return this._accessibility;
}
get keyboard(){ get keyboard(){
return this._keyboard; return this._keyboard;
} }

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
Accessibility: require('./Accessibility').Accessibility,
Browser: require('./Browser').Browser, Browser: require('./Browser').Browser,
BrowserContext: require('./Browser').BrowserContext, BrowserContext: require('./Browser').BrowserContext,
BrowserFetcher: require('./BrowserFetcher').BrowserFetcher, BrowserFetcher: require('./BrowserFetcher').BrowserFetcher,

View File

@ -9,7 +9,7 @@
"node": ">=8.9.4" "node": ">=8.9.4"
}, },
"puppeteer": { "puppeteer": {
"firefox_revision": "6237be74b2870ab50cc165b9d5be46a85091674f" "firefox_revision": "d69636bbb91f42286e81ef673b33a1459bcdfcea"
}, },
"scripts": { "scripts": {
"install": "node install.js", "install": "node install.js",

View File

@ -14,12 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
module.exports.addTests = function({testRunner, expect}) { module.exports.addTests = function({testRunner, expect, FFOX}) {
const {describe, xdescribe, fdescribe, describe_fails_ffox} = testRunner; const {describe, xdescribe, fdescribe, describe_fails_ffox} = testRunner;
const {it, fit, xit} = testRunner; const {it, fit, xit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe_fails_ffox('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>
@ -42,7 +42,24 @@ module.exports.addTests = function({testRunner, expect}) {
</select> </select>
</body>`); </body>`);
expect(await page.accessibility.snapshot()).toEqual({ const golden = FFOX ? {
role: 'document',
name: 'Accessibility Test',
children: [
{role: 'text leaf', name: 'Hello World'},
{role: 'heading', name: 'Inputs', level: 1},
{role: 'entry', name: 'Empty input', focused: true},
{role: 'entry', name: 'readonly input', readonly: true},
{role: 'entry', name: 'disabled input', disabled: true},
{role: 'entry', name: 'Input with whitespace', value: ' '},
{role: 'entry', name: '', value: 'value only'},
{role: 'entry', 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: 'combobox', name: '', value: 'First Option', haspopup: true, children: [
{role: 'combobox option', name: 'First Option', selected: true},
{role: 'combobox option', name: 'Second Option'}]
}]
} : {
role: 'WebArea', role: 'WebArea',
name: 'Accessibility Test', name: 'Accessibility Test',
children: [ children: [
@ -57,13 +74,24 @@ module.exports.addTests = function({testRunner, expect}) {
{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: 'combobox', name: '', value: 'First Option', children: [
{role: 'menuitem', name: 'First Option', selected: true}, {role: 'menuitem', name: 'First Option', selected: true},
{role: 'menuitem', name: 'Second Option'}]}] {role: 'menuitem', name: 'Second Option'}]
}); }]
};
expect(await page.accessibility.snapshot()).toEqual(golden);
}); });
it('should report uninteresting nodes', async function({page}) { it('should report uninteresting nodes', async function({page}) {
await page.setContent(`<textarea autofocus>hi</textarea>`); await page.setContent(`<textarea autofocus>hi</textarea>`);
const golden = FFOX ? {
expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual({ role: 'entry',
name: '',
value: 'hi',
focused: true,
multiline: true,
children: [{
role: 'text leaf',
name: 'hi'
}]
} : {
role: 'textbox', role: 'textbox',
name: '', name: '',
value: 'hi', value: 'hi',
@ -76,7 +104,33 @@ module.exports.addTests = function({testRunner, expect}) {
role: 'text', name: 'hi' role: 'text', name: 'hi'
}] }]
}] }]
};
expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual(golden);
}); });
it('roledescription', async({page}) => {
await page.setContent('<div tabIndex=-1 aria-roledescription="foo">Hi</div>');
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].roledescription).toEqual('foo');
});
it('orientation', async({page}) => {
await page.setContent('<a href="" role="slider" aria-orientation="vertical">11</a>');
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].orientation).toEqual('vertical');
});
it('autocomplete', async({page}) => {
await page.setContent('<input type="number" aria-autocomplete="list" />');
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].autocomplete).toEqual('list');
});
it('multiselectable', async({page}) => {
await page.setContent('<div role="grid" tabIndex=-1 aria-multiselectable=true>hey</div>');
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].multiselectable).toEqual(true);
});
it('keyshortcuts', async({page}) => {
await page.setContent('<div role="grid" tabIndex=-1 aria-keyshortcuts="foo">hey</div>');
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0].keyshortcuts).toEqual('foo');
}); });
describe('filtering children of leaf nodes', function() { describe('filtering children of leaf nodes', function() {
it('should not report text nodes inside controls', async function({page}) { it('should not report text nodes inside controls', async function({page}) {
@ -85,7 +139,18 @@ module.exports.addTests = function({testRunner, expect}) {
<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>`);
expect(await page.accessibility.snapshot()).toEqual({ const golden = FFOX ? {
role: 'document',
name: '',
children: [{
role: 'pagetab',
name: 'Tab1',
selected: true
}, {
role: 'pagetab',
name: 'Tab2'
}]
} : {
role: 'WebArea', role: 'WebArea',
name: '', name: '',
children: [{ children: [{
@ -96,16 +161,25 @@ module.exports.addTests = function({testRunner, expect}) {
role: 'tab', role: 'tab',
name: 'Tab2' name: 'Tab2'
}] }]
};
expect(await page.accessibility.snapshot()).toEqual(golden);
}); });
});
it('rich text editable fields should have children', async function({page}) { it('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">
</div>`); </div>`);
const snapshot = await page.accessibility.snapshot(); const golden = FFOX ? {
expect(snapshot.children[0]).toEqual({ role: 'section',
name: '',
children: [{
role: 'text leaf',
name: 'Edit this image: '
}, {
role: 'text',
name: 'my fake image'
}]
} : {
role: 'GenericContainer', role: 'GenericContainer',
name: '', name: '',
value: 'Edit this image: ', value: 'Edit this image: ',
@ -116,15 +190,24 @@ module.exports.addTests = function({testRunner, expect}) {
role: 'img', role: 'img',
name: 'my fake image' name: 'my fake image'
}] }]
}); };
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}) { 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">
</div>`); </div>`);
const snapshot = await page.accessibility.snapshot(); const golden = FFOX ? {
expect(snapshot.children[0]).toEqual({ role: 'entry',
name: '',
value: 'Edit this image: my fake image',
children: [{
role: 'text',
name: 'my fake image'
}]
} : {
role: 'textbox', role: 'textbox',
name: '', name: '',
value: 'Edit this image: ', value: 'Edit this image: ',
@ -135,8 +218,12 @@ module.exports.addTests = function({testRunner, expect}) {
role: 'img', role: 'img',
name: 'my fake image' name: 'my fake image'
}] }]
};
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0]).toEqual(golden);
}); });
}); // Firefox does not support contenteditable="plaintext-only".
!FFOX && 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>`);
@ -165,18 +252,24 @@ module.exports.addTests = function({testRunner, expect}) {
name: '' name: ''
}); });
}); });
});
it('non editable textbox with role and tabIndex and label should not have children', async function({page}) { it('non editable textbox with role and tabIndex and label should not have children', async function({page}) {
await page.setContent(` await page.setContent(`
<div role="textbox" tabIndex=0 aria-checked="true" aria-label="my favorite textbox"> <div role="textbox" tabIndex=0 aria-checked="true" aria-label="my favorite textbox">
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 snapshot = await page.accessibility.snapshot(); const golden = FFOX ? {
expect(snapshot.children[0]).toEqual({ role: 'entry',
name: 'my favorite textbox',
value: 'this is the inner content yo'
} : {
role: 'textbox', role: 'textbox',
name: 'my favorite textbox', name: 'my favorite textbox',
value: 'this is the inner content ' value: 'this is the inner content '
}); };
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0]).toEqual(golden);
}); });
it('checkbox with and tabIndex and label should not have children', async function({page}) { it('checkbox with and tabIndex and label should not have children', async function({page}) {
await page.setContent(` await page.setContent(`
@ -184,12 +277,17 @@ module.exports.addTests = function({testRunner, expect}) {
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 snapshot = await page.accessibility.snapshot(); const golden = FFOX ? {
expect(snapshot.children[0]).toEqual({ role: 'checkbutton',
name: 'my favorite checkbox',
checked: true
} : {
role: 'checkbox', role: 'checkbox',
name: 'my favorite checkbox', name: 'my favorite checkbox',
checked: true checked: true
}); };
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0]).toEqual(golden);
}); });
it('checkbox without label should not have children', async function({page}) { it('checkbox without label should not have children', async function({page}) {
await page.setContent(` await page.setContent(`
@ -197,12 +295,17 @@ module.exports.addTests = function({testRunner, expect}) {
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 snapshot = await page.accessibility.snapshot(); const golden = FFOX ? {
expect(snapshot.children[0]).toEqual({ role: 'checkbutton',
name: 'this is the inner content yo',
checked: true
} : {
role: 'checkbox', role: 'checkbox',
name: 'this is the inner content yo', name: 'this is the inner content yo',
checked: true checked: true
}); };
const snapshot = await page.accessibility.snapshot();
expect(snapshot.children[0]).toEqual(golden);
}); });
}); });
function findFocusedNode(node) { function findFocusedNode(node) {

View File

@ -122,6 +122,7 @@ module.exports.addTests = ({testRunner, product, puppeteerPath}) => {
// Page-level tests that are given a browser, a context and a page. // Page-level tests that are given a browser, a context and a page.
// Each test is launched in a new browser context. // Each test is launched in a new browser context.
require('./accessibility.spec.js').addTests(testOptions);
require('./browser.spec.js').addTests(testOptions); require('./browser.spec.js').addTests(testOptions);
require('./click.spec.js').addTests(testOptions); require('./click.spec.js').addTests(testOptions);
require('./cookies.spec.js').addTests(testOptions); require('./cookies.spec.js').addTests(testOptions);
@ -144,7 +145,6 @@ module.exports.addTests = ({testRunner, product, puppeteerPath}) => {
require('./waittask.spec.js').addTests(testOptions); require('./waittask.spec.js').addTests(testOptions);
require('./worker.spec.js').addTests(testOptions); require('./worker.spec.js').addTests(testOptions);
if (CHROME) { if (CHROME) {
require('./accessibility.spec.js').addTests(testOptions);
require('./CDPSession.spec.js').addTests(testOptions); require('./CDPSession.spec.js').addTests(testOptions);
require('./coverage.spec.js').addTests(testOptions); require('./coverage.spec.js').addTests(testOptions);
// Add page-level Chromium-specific tests. // Add page-level Chromium-specific tests.