From 03d06f54d67a314b77729aba38b214f599062a19 Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Mon, 25 Feb 2019 21:57:33 -0800 Subject: [PATCH] feat(firefox): page.accessibility.snapshot() (#4071) --- .../puppeteer-firefox/lib/Accessibility.js | 322 ++++++++++++++++++ experimental/puppeteer-firefox/lib/Page.js | 10 +- experimental/puppeteer-firefox/lib/api.js | 1 + experimental/puppeteer-firefox/package.json | 2 +- test/accessibility.spec.js | 203 ++++++++--- test/puppeteer.spec.js | 2 +- 6 files changed, 486 insertions(+), 54 deletions(-) create mode 100644 experimental/puppeteer-firefox/lib/Accessibility.js diff --git a/experimental/puppeteer-firefox/lib/Accessibility.js b/experimental/puppeteer-firefox/lib/Accessibility.js new file mode 100644 index 00000000..163576da --- /dev/null +++ b/experimental/puppeteer-firefox/lib/Accessibility.js @@ -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=} children + */ + +class Accessibility { + constructor(session) { + this._session = session; + } + + /** + * @param {{interestingOnly?: boolean}=} options + * @return {!Promise} + */ + 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} */ + const interestingNodes = new Set(); + collectInterestingNodes(interestingNodes, root, false); + return serializeTree(root, interestingNodes)[0]; + } +} + +/** + * @param {!Set} 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=} whitelistedNodes + * @return {!Array} + */ +function serializeTree(node, whitelistedNodes) { + /** @type {!Array} */ + 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} */ + 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} */ + 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} */ + 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} */ + 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} */ + const numericalProperties = [ + 'level', + 'valuemax', + 'valuemin', + ]; + for (const numericalProperty of numericalProperties) { + if (!(numericalProperty in this._payload)) + continue; + node[numericalProperty] = this._payload[numericalProperty]; + } + /** @type {!Array} */ + 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}; diff --git a/experimental/puppeteer-firefox/lib/Page.js b/experimental/puppeteer-firefox/lib/Page.js index 64a9fde4..63a70861 100644 --- a/experimental/puppeteer-firefox/lib/Page.js +++ b/experimental/puppeteer-firefox/lib/Page.js @@ -11,7 +11,8 @@ const {Events} = require('./Events'); const {FrameManager, normalizeWaitUntil} = require('./FrameManager'); const {NetworkManager} = require('./NetworkManager'); const {TimeoutSettings} = require('./TimeoutSettings'); -const {NavigationWatchdog, NextNavigationWatchdog} = require('./NavigationWatchdog'); +const {NavigationWatchdog} = require('./NavigationWatchdog'); +const {Accessibility} = require('./Accessibility'); const writeFileAsync = util.promisify(fs.writeFile); @@ -47,6 +48,7 @@ class Page extends EventEmitter { this._keyboard = new Keyboard(session); this._mouse = new Mouse(session, this._keyboard); this._touchscreen = new Touchscreen(session, this._keyboard, this._mouse); + this._accessibility = new Accessibility(session); this._closed = false; /** @type {!Map} */ this._pageBindings = new Map(); @@ -266,7 +268,7 @@ class Page extends EventEmitter { } _onUncaughtError(params) { - let error = new Error(params.message); + const error = new Error(params.message); error.stack = params.stack; this.emit(Events.Page.PageError, error); } @@ -330,6 +332,10 @@ class Page extends EventEmitter { return this._frameManager.mainFrame(); } + get accessibility() { + return this._accessibility; + } + get keyboard(){ return this._keyboard; } diff --git a/experimental/puppeteer-firefox/lib/api.js b/experimental/puppeteer-firefox/lib/api.js index fbd92299..c927d6c4 100644 --- a/experimental/puppeteer-firefox/lib/api.js +++ b/experimental/puppeteer-firefox/lib/api.js @@ -1,4 +1,5 @@ module.exports = { + Accessibility: require('./Accessibility').Accessibility, Browser: require('./Browser').Browser, BrowserContext: require('./Browser').BrowserContext, BrowserFetcher: require('./BrowserFetcher').BrowserFetcher, diff --git a/experimental/puppeteer-firefox/package.json b/experimental/puppeteer-firefox/package.json index 985c0749..9b8fe136 100644 --- a/experimental/puppeteer-firefox/package.json +++ b/experimental/puppeteer-firefox/package.json @@ -9,7 +9,7 @@ "node": ">=8.9.4" }, "puppeteer": { - "firefox_revision": "6237be74b2870ab50cc165b9d5be46a85091674f" + "firefox_revision": "d69636bbb91f42286e81ef673b33a1459bcdfcea" }, "scripts": { "install": "node install.js", diff --git a/test/accessibility.spec.js b/test/accessibility.spec.js index 1a3db0b2..c48e59f3 100644 --- a/test/accessibility.spec.js +++ b/test/accessibility.spec.js @@ -14,12 +14,12 @@ * 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 {it, fit, xit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - describe_fails_ffox('Accessibility', function() { + describe('Accessibility', function() { it('should work', async function({page}) { await page.setContent(` @@ -42,7 +42,24 @@ module.exports.addTests = function({testRunner, expect}) { `); - 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', name: 'Accessibility Test', 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: 'combobox', name: '', value: 'First Option', children: [ {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}) { await page.setContent(``); - - expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual({ + const golden = FFOX ? { + role: 'entry', + name: '', + value: 'hi', + focused: true, + multiline: true, + children: [{ + role: 'text leaf', + name: 'hi' + }] + } : { role: 'textbox', name: '', value: 'hi', @@ -76,7 +104,33 @@ module.exports.addTests = function({testRunner, expect}) { role: 'text', name: 'hi' }] }] - }); + }; + expect(findFocusedNode(await page.accessibility.snapshot({interestingOnly: false}))).toEqual(golden); + }); + it('roledescription', async({page}) => { + await page.setContent('
Hi
'); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].roledescription).toEqual('foo'); + }); + it('orientation', async({page}) => { + await page.setContent('11'); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].orientation).toEqual('vertical'); + }); + it('autocomplete', async({page}) => { + await page.setContent(''); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].autocomplete).toEqual('list'); + }); + it('multiselectable', async({page}) => { + await page.setContent('
hey
'); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].multiselectable).toEqual(true); + }); + it('keyshortcuts', async({page}) => { + await page.setContent('
hey
'); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].keyshortcuts).toEqual('foo'); }); describe('filtering children of leaf nodes', function() { it('should not report text nodes inside controls', async function({page}) { @@ -85,7 +139,18 @@ module.exports.addTests = function({testRunner, expect}) {
Tab1
Tab2
`); - 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', name: '', children: [{ @@ -96,16 +161,25 @@ module.exports.addTests = function({testRunner, expect}) { role: 'tab', name: 'Tab2' }] - }); + }; + expect(await page.accessibility.snapshot()).toEqual(golden); }); - it('rich text editable fields should have children', async function({page}) { await page.setContent(`
Edit this image: my fake image
`); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ + const golden = FFOX ? { + role: 'section', + name: '', + children: [{ + role: 'text leaf', + name: 'Edit this image: ' + }, { + role: 'text', + name: 'my fake image' + }] + } : { role: 'GenericContainer', name: '', value: 'Edit this image: ', @@ -116,15 +190,24 @@ module.exports.addTests = function({testRunner, expect}) { role: 'img', 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}) { await page.setContent(`
Edit this image: my fake image
`); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ + const golden = FFOX ? { + role: 'entry', + name: '', + value: 'Edit this image: my fake image', + children: [{ + role: 'text', + name: 'my fake image' + }] + } : { role: 'textbox', name: '', value: 'Edit this image: ', @@ -135,34 +218,39 @@ module.exports.addTests = function({testRunner, expect}) { role: 'img', name: 'my fake image' }] - }); - }); - it('plain text field with role should not have children', async function({page}) { - await page.setContent(` -
Edit this image:my fake image
`); + }; const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ - role: 'textbox', - name: '', - value: 'Edit this image:' - }); + expect(snapshot.children[0]).toEqual(golden); }); - it('plain text field without role should not have content', async function({page}) { - await page.setContent(` -
Edit this image:my fake image
`); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ - role: 'GenericContainer', - name: '' + // 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}) { + await page.setContent(` +
Edit this image:my fake image
`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'textbox', + name: '', + value: 'Edit this image:' + }); }); - }); - it('plain text field with tabindex and without role should not have content', async function({page}) { - await page.setContent(` -
Edit this image:my fake image
`); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ - role: 'GenericContainer', - name: '' + it('plain text field without role should not have content', async function({page}) { + await page.setContent(` +
Edit this image:my fake image
`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'GenericContainer', + name: '' + }); + }); + it('plain text field with tabindex and without role should not have content', async function({page}) { + await page.setContent(` +
Edit this image:my fake image
`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'GenericContainer', + name: '' + }); }); }); it('non editable textbox with role and tabIndex and label should not have children', async function({page}) { @@ -171,12 +259,17 @@ module.exports.addTests = function({testRunner, expect}) { this is the inner content yo `); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ + const golden = FFOX ? { + role: 'entry', + name: 'my favorite textbox', + value: 'this is the inner content yo' + } : { role: 'textbox', name: 'my favorite textbox', 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}) { await page.setContent(` @@ -184,12 +277,17 @@ module.exports.addTests = function({testRunner, expect}) { this is the inner content yo `); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ + const golden = FFOX ? { + role: 'checkbutton', + name: 'my favorite checkbox', + checked: true + } : { role: 'checkbox', name: 'my favorite checkbox', 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}) { await page.setContent(` @@ -197,12 +295,17 @@ module.exports.addTests = function({testRunner, expect}) { this is the inner content yo `); - const snapshot = await page.accessibility.snapshot(); - expect(snapshot.children[0]).toEqual({ + const golden = FFOX ? { + role: 'checkbutton', + name: 'this is the inner content yo', + checked: true + } : { role: 'checkbox', name: 'this is the inner content yo', checked: true - }); + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); }); }); function findFocusedNode(node) { diff --git a/test/puppeteer.spec.js b/test/puppeteer.spec.js index de8611db..81acb29f 100644 --- a/test/puppeteer.spec.js +++ b/test/puppeteer.spec.js @@ -122,6 +122,7 @@ module.exports.addTests = ({testRunner, product, puppeteerPath}) => { // Page-level tests that are given a browser, a context and a page. // Each test is launched in a new browser context. + require('./accessibility.spec.js').addTests(testOptions); require('./browser.spec.js').addTests(testOptions); require('./click.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('./worker.spec.js').addTests(testOptions); if (CHROME) { - require('./accessibility.spec.js').addTests(testOptions); require('./CDPSession.spec.js').addTests(testOptions); require('./coverage.spec.js').addTests(testOptions); // Add page-level Chromium-specific tests.