diff --git a/src/accessibility.ts b/src/accessibility.ts new file mode 100644 index 0000000000..8673c94f1f --- /dev/null +++ b/src/accessibility.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import * as dom from './dom'; + +export type SerializedAXNode = { + role: string, + name: string, + value?: string|number, + description?: string, + + keyshortcuts?: string, + roledescription?: string, + valuetext?: string, + + disabled?: boolean, + expanded?: boolean, + focused?: boolean, + modal?: boolean, + multiline?: boolean, + multiselectable?: boolean, + readonly?: boolean, + required?: boolean, + selected?: boolean, + + checked?: boolean|'mixed', + pressed?: boolean|'mixed', + + level?: number, + valuemin?: number, + valuemax?: number, + + autocomplete?: string, + haspopup?: string, + invalid?: string, + orientation?: string, + + children?: SerializedAXNode[] +}; + +export interface AXNode { + isInteresting(insideControl: boolean): boolean; + isLeafNode(): boolean; + isControl(): boolean; + serialize(): SerializedAXNode; + findElement(element: dom.ElementHandle): Promise; + children(): Iterable; +} + +export class Accessibility { + private _getAXTree: () => Promise; + constructor(getAXTree: () => Promise) { + this._getAXTree = getAXTree; + } + + async snapshot(options: { + interestingOnly?: boolean; + root?: dom.ElementHandle | null; + } = {}): Promise { + const { + interestingOnly = true, + root = null, + } = options; + const defaultRoot = await this._getAXTree(); + let needle = defaultRoot; + if (root) { + needle = await defaultRoot.findElement(root); + if (!needle) + return null; + } + if (!interestingOnly) + return serializeTree(needle)[0]; + + const interestingNodes: Set = new Set(); + collectInterestingNodes(interestingNodes, defaultRoot, false); + if (root && !interestingNodes.has(needle)) + return null; + return serializeTree(needle, interestingNodes)[0]; + } +} + +function collectInterestingNodes(collection: Set, node: AXNode, insideControl: boolean) { + 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); +} + +function serializeTree(node: AXNode, whitelistedNodes?: Set): SerializedAXNode[] { + const children: SerializedAXNode[] = []; + 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]; +} diff --git a/src/api.ts b/src/api.ts index 09d07907f1..f792300d30 100644 --- a/src/api.ts +++ b/src/api.ts @@ -13,6 +13,7 @@ export { Keyboard, Mouse } from './input'; export { JSHandle } from './javascript'; export { Request, Response } from './network'; export { Page, FileChooser } from './page'; +export { Accessibility } from './accessibility'; export * from './chromium/crApi'; export * from './firefox/ffApi'; diff --git a/src/chromium/features/crAccessibility.ts b/src/chromium/crAccessibility.ts similarity index 69% rename from src/chromium/features/crAccessibility.ts rename to src/chromium/crAccessibility.ts index 1ba9cab7d4..d1729193d2 100644 --- a/src/chromium/features/crAccessibility.ts +++ b/src/chromium/crAccessibility.ts @@ -15,111 +15,17 @@ * limitations under the License. */ -import { CRSession } from '../crConnection'; -import { Protocol } from '../protocol'; -import * as dom from '../../dom'; +import { CRSession } from './crConnection'; +import { Protocol } from './protocol'; +import * as dom from '../dom'; +import * as accessibility from '../accessibility'; -type SerializedAXNode = { - role: string, - name?: string, - value?: string|number, - description?: string, - - keyshortcuts?: string, - roledescription?: string, - valuetext?: string, - - disabled?: boolean, - expanded?: boolean, - focused?: boolean, - modal?: boolean, - multiline?: boolean, - multiselectable?: boolean, - readonly?: boolean, - required?: boolean, - selected?: boolean, - - checked?: boolean|'mixed', - pressed?: boolean|'mixed', - - level?: number, - valuemin?: number, - valuemax?: number, - - autocomplete?: string, - haspopup?: string, - invalid?: string, - orientation?: string, - - children?: SerializedAXNode[] -}; - -export class CRAccessibility { - private _client: CRSession; - - constructor(client: CRSession) { - this._client = client; - } - - async snapshot(options: { - interestingOnly?: boolean; - root?: dom.ElementHandle | null; - } = {}): Promise { - const { - interestingOnly = true, - root = null, - } = options; - const {nodes} = await this._client.send('Accessibility.getFullAXTree'); - let backendNodeId: number | null = null; - if (root) { - const remoteObject = root._remoteObject as Protocol.Runtime.RemoteObject; - const {node} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId}); - backendNodeId = node.backendNodeId; - } - const defaultRoot = CRAXNode.createTree(nodes); - let needle = defaultRoot; - if (backendNodeId) { - needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId); - if (!needle) - return null; - } - if (!interestingOnly) - return serializeTree(needle)[0]; - - const interestingNodes: Set = new Set(); - collectInterestingNodes(interestingNodes, defaultRoot, false); - if (!interestingNodes.has(needle)) - return null; - return serializeTree(needle, interestingNodes)[0]; - } +export async function getAccessibilityTree(client: CRSession) : Promise { + const {nodes} = await client.send('Accessibility.getFullAXTree'); + return CRAXNode.createTree(client, nodes); } -function collectInterestingNodes(collection: Set, node: CRAXNode, insideControl: boolean) { - 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); -} - -function serializeTree(node: CRAXNode, whitelistedNodes?: Set): SerializedAXNode[] { - const children: SerializedAXNode[] = []; - 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 CRAXNode { +class CRAXNode implements accessibility.AXNode { _payload: Protocol.Accessibility.AXNode; _children: CRAXNode[] = []; private _richlyEditable = false; @@ -130,8 +36,10 @@ class CRAXNode { private _name: string; private _role: string; private _cachedHasFocusableChild: boolean | undefined; + private _client: CRSession; - constructor(payload: Protocol.Accessibility.AXNode) { + constructor(client: CRSession, payload: Protocol.Accessibility.AXNode) { + this._client = client; this._payload = payload; this._name = this._payload.name ? this._payload.name.value : ''; @@ -178,6 +86,17 @@ class CRAXNode { return this._cachedHasFocusableChild; } + children() { + return this._children; + } + + async findElement(element: dom.ElementHandle): Promise { + const remoteObject = element._remoteObject as Protocol.Runtime.RemoteObject; + const {node: {backendNodeId}} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId}); + const needle = this.find(node => node._payload.backendDOMNodeId === backendNodeId); + return needle || null; + } + find(predicate: (arg0: CRAXNode) => boolean): CRAXNode | null { if (predicate(this)) return this; @@ -275,7 +194,7 @@ class CRAXNode { return this.isLeafNode() && !!this._name; } - serialize(): SerializedAXNode { + serialize(): accessibility.SerializedAXNode { const properties: Map = new Map(); for (const property of this._payload.properties || []) properties.set(property.name.toLowerCase(), property.value.value); @@ -286,12 +205,12 @@ class CRAXNode { if (this._payload.description) properties.set('description', this._payload.description.value); - const node: {[x in keyof SerializedAXNode]: any} = { - role: this._role + const node: {[x in keyof accessibility.SerializedAXNode]: any} = { + role: this._role, + name: this._payload.name.value || '' }; - const userStringProperties: Array = [ - 'name', + const userStringProperties: Array = [ 'value', 'description', 'keyshortcuts', @@ -304,7 +223,7 @@ class CRAXNode { node[userStringProperty] = properties.get(userStringProperty); } - const booleanProperties: Array = [ + const booleanProperties: Array = [ 'disabled', 'expanded', 'focused', @@ -326,7 +245,7 @@ class CRAXNode { node[booleanProperty] = value; } - const tristateProperties: Array = [ + const tristateProperties: Array = [ 'checked', 'pressed', ]; @@ -336,7 +255,7 @@ class CRAXNode { const value = properties.get(tristateProperty); node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false; } - const numericalProperties: Array = [ + const numericalProperties: Array = [ 'level', 'valuemax', 'valuemin', @@ -346,7 +265,7 @@ class CRAXNode { continue; node[numericalProperty] = properties.get(numericalProperty); } - const tokenProperties: Array = [ + const tokenProperties: Array = [ 'autocomplete', 'haspopup', 'invalid', @@ -358,13 +277,13 @@ class CRAXNode { continue; node[tokenProperty] = value; } - return node as SerializedAXNode; + return node as accessibility.SerializedAXNode; } - static createTree(payloads: Protocol.Accessibility.AXNode[]): CRAXNode { + static createTree(client: CRSession, payloads: Protocol.Accessibility.AXNode[]): CRAXNode { const nodeById: Map = new Map(); for (const payload of payloads) - nodeById.set(payload.nodeId, new CRAXNode(payload)); + nodeById.set(payload.nodeId, new CRAXNode(client, payload)); for (const node of nodeById.values()) { for (const childId of node._payload.childIds || []) node._children.push(nodeById.get(childId)); diff --git a/src/chromium/crApi.ts b/src/chromium/crApi.ts index 31b208f66f..a22a10b119 100644 --- a/src/chromium/crApi.ts +++ b/src/chromium/crApi.ts @@ -6,6 +6,5 @@ export { CRSession as ChromiumSession } from './crConnection'; export { ChromiumPage } from './crPage'; export { CRPlaywright as ChromiumPlaywright } from './crPlaywright'; export { CRTarget as ChromiumTarget } from './crTarget'; -export { CRAccessibility as ChromiumAccessibility } from './features/crAccessibility'; export { CRCoverage as ChromiumCoverage } from './features/crCoverage'; export { CRWorker as ChromiumWorker } from './features/crWorkers'; diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index aad5481c4d..503bbf128c 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -29,7 +29,7 @@ import { toConsoleMessageLocation, exceptionToError, releaseObject } from './crP import * as dialog from '../dialog'; import { PageDelegate } from '../page'; import { RawMouseImpl, RawKeyboardImpl } from './crInput'; -import { CRAccessibility } from './features/crAccessibility'; +import { getAccessibilityTree } from './crAccessibility'; import { CRCoverage } from './features/crCoverage'; import { CRPDF, PDFOptions } from './features/crPdf'; import { CRWorkers, CRWorker } from './features/crWorkers'; @@ -38,6 +38,7 @@ import { BrowserContext } from '../browserContext'; import * as types from '../types'; import * as input from '../input'; import { ConsoleMessage } from '../console'; +import * as accessibility from '../accessibility'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -478,10 +479,13 @@ export class CRPage implements PageDelegate { throw new Error('Unable to adopt element handle from a different document'); return to._createHandle(result.object).asElement()!; } + + async getAccessibilityTree(): Promise { + return getAccessibilityTree(this._client); + } } export class ChromiumPage extends Page { - readonly accessibility: CRAccessibility; readonly coverage: CRCoverage; private _pdf: CRPDF; private _workers: CRWorkers; @@ -489,7 +493,6 @@ export class ChromiumPage extends Page { constructor(client: CRSession, delegate: CRPage, browserContext: BrowserContext) { super(delegate, browserContext); - this.accessibility = new CRAccessibility(client); this.coverage = new CRCoverage(client); this._pdf = new CRPDF(client); this._workers = new CRWorkers(client, this, this._addConsoleMessage.bind(this), error => this.emit(Events.Page.PageError, error)); diff --git a/src/firefox/features/ffAccessibility.ts b/src/firefox/ffAccessibility.ts similarity index 69% rename from src/firefox/features/ffAccessibility.ts rename to src/firefox/ffAccessibility.ts index d36bcce1be..39b52d0f49 100644 --- a/src/firefox/features/ffAccessibility.ts +++ b/src/firefox/ffAccessibility.ts @@ -14,78 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as accessibility from '../accessibility'; +import { FFSession } from './ffConnection'; -interface SerializedAXNode { - role: string; - - name?: string; - value?: string|number; - description?: string; - - keyshortcuts?: string; - roledescription?: string; - valuetext?: string; - - disabled?: boolean; - expanded?: boolean; - focused?: boolean; - modal?: boolean; - multiline?: boolean; - multiselectable?: boolean; - readonly?: boolean; - required?: boolean; - selected?: boolean; - - checked?: boolean|'mixed'; - pressed?: boolean|'mixed'; - - level?: number; - - autocomplete?: string; - haspopup?: string; - invalid?: string; - orientation?: string; - - children?: Array; +export async function getAccessibilityTree(session: FFSession) : Promise { + const { tree } = await session.send('Accessibility.getFullAXTree'); + return new FFAXNode(tree); } -export class FFAccessibility { - _session: any; - constructor(session) { - this._session = session; - } - async snapshot(options: { interestingOnly?: boolean; } | undefined = {}): Promise { - const { interestingOnly = true } = options; - const { tree } = await this._session.send('Accessibility.getFullAXTree'); - const root = new AXNode(tree); - if (!interestingOnly) - return serializeTree(root)[0]; - const interestingNodes: Set = new Set(); - collectInterestingNodes(interestingNodes, root, false); - return serializeTree(root, interestingNodes)[0]; - } -} -function collectInterestingNodes(collection: Set, node: AXNode, insideControl: boolean) { - 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); -} -function serializeTree(node: AXNode, whitelistedNodes?: Set): Array { - const children: Array = []; - 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 { - _children: AXNode[]; + +class FFAXNode implements accessibility.AXNode { + _children: FFAXNode[]; private _payload: any; private _editable: boolean; private _richlyEditable: boolean; @@ -97,7 +35,7 @@ class AXNode { constructor(payload) { this._payload = payload; - this._children = (payload.children || []).map(x => new AXNode(x)); + this._children = (payload.children || []).map(x => new FFAXNode(x)); this._editable = payload.editable; this._richlyEditable = this._editable && (payload.tag !== 'textarea' && payload.tag !== 'input'); this._focusable = payload.focusable; @@ -133,6 +71,14 @@ class AXNode { return this._cachedHasFocusableChild; } + children() { + return this._children; + } + + async findElement(): Promise { + throw new Error('Not implimented'); + } + isLeafNode(): boolean { if (!this._children.length) return true; @@ -210,11 +156,12 @@ class AXNode { return this.isLeafNode() && !!this._name.trim(); } - serialize(): SerializedAXNode { - const node: {[x in keyof SerializedAXNode]: any} = { - role: this._role + serialize(): accessibility.SerializedAXNode { + const node: {[x in keyof accessibility.SerializedAXNode]: any} = { + role: this._role, + name: this._name || '' }; - const userStringProperties: Array = [ + const userStringProperties: Array = [ 'name', 'value', 'description', @@ -227,7 +174,7 @@ class AXNode { continue; node[userStringProperty] = this._payload[userStringProperty]; } - const booleanProperties: Array = [ + const booleanProperties: Array = [ 'disabled', 'expanded', 'focused', @@ -246,7 +193,7 @@ class AXNode { continue; node[booleanProperty] = value; } - const tristateProperties: Array = [ + const tristateProperties: Array = [ 'checked', 'pressed', ]; @@ -256,7 +203,7 @@ class AXNode { const value = this._payload[tristateProperty]; node[tristateProperty] = value; } - const numericalProperties: Array = [ + const numericalProperties: Array = [ 'level' ]; for (const numericalProperty of numericalProperties) { @@ -264,7 +211,7 @@ class AXNode { continue; node[numericalProperty] = this._payload[numericalProperty]; } - const tokenProperties: Array = [ + const tokenProperties: Array = [ 'autocomplete', 'haspopup', 'invalid', diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 318bf2e062..f68bc4ac47 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -28,9 +28,10 @@ import { Protocol } from './protocol'; import * as input from '../input'; import { RawMouseImpl, RawKeyboardImpl } from './ffInput'; import { BrowserContext } from '../browserContext'; -import { FFAccessibility } from './features/ffAccessibility'; +import { getAccessibilityTree } from './ffAccessibility'; import * as network from '../network'; import * as types from '../types'; +import * as accessibility from '../accessibility'; export class FFPage implements PageDelegate { readonly rawMouse: RawMouseImpl; @@ -64,7 +65,6 @@ export class FFPage implements PageDelegate { helper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)), helper.addEventListener(this._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)), ]; - (this._page as any).accessibility = new FFAccessibility(session); } async _initialize() { @@ -347,6 +347,10 @@ export class FFPage implements PageDelegate { assert(false, 'Multiple isolated worlds are not implemented'); return handle; } + + async getAccessibilityTree() : Promise { + return getAccessibilityTree(this._session); + } } export function normalizeWaitUntil(waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[]): frames.LifecycleEvent[] { diff --git a/src/page.ts b/src/page.ts index a1b11029cd..bae2ae09c4 100644 --- a/src/page.ts +++ b/src/page.ts @@ -29,6 +29,7 @@ import { Events } from './events'; import { BrowserContext } from './browserContext'; import { ConsoleMessage, ConsoleMessageLocation } from './console'; import Injected from './injected/injected'; +import * as accessibility from './accessibility'; export interface PageDelegate { readonly rawMouse: input.RawMouse; @@ -66,6 +67,8 @@ export interface PageDelegate { layoutViewport(): Promise<{ width: number, height: number }>; setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise; getBoundingBox(handle: dom.ElementHandle): Promise; + + getAccessibilityTree(): Promise; } type PageState = { @@ -100,6 +103,7 @@ export class Page extends EventEmitter { private _pageBindings = new Map(); readonly _screenshotter: Screenshotter; readonly _frameManager: frames.FrameManager; + readonly accessibility: accessibility.Accessibility; constructor(delegate: PageDelegate, browserContext: BrowserContext) { super(); @@ -117,6 +121,7 @@ export class Page extends EventEmitter { offlineMode: null, credentials: null }; + this.accessibility = new accessibility.Accessibility(delegate.getAccessibilityTree.bind(delegate)); this.keyboard = new input.Keyboard(delegate.rawKeyboard); this.mouse = new input.Mouse(delegate.rawMouse, this.keyboard); this._timeoutSettings = new TimeoutSettings(); 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 bd36097db8..4f7182d1fe 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -33,6 +33,8 @@ import * as input from '../input'; 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__'; @@ -480,6 +482,10 @@ export class WKPage implements PageDelegate { throw new Error('Unable to adopt element handle from a different document'); return to._createHandle(result.object) as dom.ElementHandle; } + + async getAccessibilityTree() : Promise { + return getAccessibilityTree(this._session); + } } function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject { diff --git a/test/features/accessibility.spec.js b/test/accessibility.spec.js similarity index 87% rename from test/features/accessibility.spec.js rename to test/accessibility.spec.js index d55282b27a..17923058b1 100644 --- a/test/features/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; - describe.skip(WEBKIT)('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(``); diff --git a/test/playwright.spec.js b/test/playwright.spec.js index b5f08ead9b..d1c23190af 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -148,6 +148,7 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { // Page-level tests that are given a browser, a context and a page. // Each test is launched in a new browser context. + testRunner.loadTests(require('./accessibility.spec.js'), testOptions); testRunner.loadTests(require('./click.spec.js'), testOptions); testRunner.loadTests(require('./cookies.spec.js'), testOptions); testRunner.loadTests(require('./dialog.spec.js'), testOptions); @@ -177,7 +178,6 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { } if (CHROME || FFOX) { - testRunner.loadTests(require('./features/accessibility.spec.js'), testOptions); testRunner.loadTests(require('./features/permissions.spec.js'), testOptions); }