move accessibility out of features

This commit is contained in:
Joel Einbinder 2019-12-26 13:11:15 -08:00
parent 6a04e1f026
commit ab226d0545
11 changed files with 184 additions and 199 deletions

103
src/accessibility.ts Normal file
View file

@ -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<AXNode|null>;
children(): Iterable<AXNode>;
}
export class Accessibility {
private _getAXTree: () => Promise<AXNode>;
constructor(getAXTree: () => Promise<AXNode>) {
this._getAXTree = getAXTree;
}
async snapshot(options: {
interestingOnly?: boolean;
root?: dom.ElementHandle | null;
} = {}): Promise<SerializedAXNode> {
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<AXNode> = new Set();
collectInterestingNodes(interestingNodes, defaultRoot, false);
if (!interestingNodes.has(needle))
return null;
return serializeTree(needle, interestingNodes)[0];
}
}
function collectInterestingNodes(collection: Set<AXNode>, 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<AXNode>): 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];
}

View file

@ -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';

View file

@ -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;
export async function getAccessibilityTree(client: CRSession) : Promise<accessibility.AXNode> {
const {nodes} = await client.send('Accessibility.getFullAXTree');
return CRAXNode.createTree(client, nodes);
}
async snapshot(options: {
interestingOnly?: boolean;
root?: dom.ElementHandle | null;
} = {}): Promise<SerializedAXNode> {
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<CRAXNode> = new Set();
collectInterestingNodes(interestingNodes, defaultRoot, false);
if (!interestingNodes.has(needle))
return null;
return serializeTree(needle, interestingNodes)[0];
}
}
function collectInterestingNodes(collection: Set<CRAXNode>, 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<CRAXNode>): 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<CRAXNode | null> {
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<string, number | string | boolean> = new Map();
for (const property of this._payload.properties || [])
properties.set(property.name.toLowerCase(), property.value.value);
@ -286,11 +205,11 @@ class CRAXNode {
if (this._payload.description)
properties.set('description', this._payload.description.value);
const node: {[x in keyof SerializedAXNode]: any} = {
const node: {[x in keyof accessibility.SerializedAXNode]: any} = {
role: this._role
};
const userStringProperties: Array<keyof SerializedAXNode> = [
const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [
'name',
'value',
'description',
@ -304,7 +223,7 @@ class CRAXNode {
node[userStringProperty] = properties.get(userStringProperty);
}
const booleanProperties: Array<keyof SerializedAXNode> = [
const booleanProperties: Array<keyof accessibility.SerializedAXNode> = [
'disabled',
'expanded',
'focused',
@ -326,7 +245,7 @@ class CRAXNode {
node[booleanProperty] = value;
}
const tristateProperties: Array<keyof SerializedAXNode> = [
const tristateProperties: Array<keyof accessibility.SerializedAXNode> = [
'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<keyof SerializedAXNode> = [
const numericalProperties: Array<keyof accessibility.SerializedAXNode> = [
'level',
'valuemax',
'valuemin',
@ -346,7 +265,7 @@ class CRAXNode {
continue;
node[numericalProperty] = properties.get(numericalProperty);
}
const tokenProperties: Array<keyof SerializedAXNode> = [
const tokenProperties: Array<keyof accessibility.SerializedAXNode> = [
'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<string, CRAXNode> = 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));

View file

@ -6,7 +6,6 @@ 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 { CROverrides as ChromiumOverrides } from './features/crOverrides';
export { CRWorker as ChromiumWorker } from './features/crWorkers';

View file

@ -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__';
@ -458,10 +459,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<accessibility.AXNode> {
return getAccessibilityTree(this._client);
}
}
export class ChromiumPage extends Page {
readonly accessibility: CRAccessibility;
readonly coverage: CRCoverage;
private _pdf: CRPDF;
private _workers: CRWorkers;
@ -469,7 +473,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));

View file

@ -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<SerializedAXNode>;
export async function getAccessibilityTree(session: FFSession) : Promise<accessibility.AXNode> {
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<SerializedAXNode> {
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<AXNode> = new Set();
collectInterestingNodes(interestingNodes, root, false);
return serializeTree(root, interestingNodes)[0];
}
}
function collectInterestingNodes(collection: Set<AXNode>, 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<AXNode>): Array<SerializedAXNode> {
const children: Array<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 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<FFAXNode | null> {
throw new Error('Not implimented');
}
isLeafNode(): boolean {
if (!this._children.length)
return true;
@ -210,11 +156,11 @@ class AXNode {
return this.isLeafNode() && !!this._name.trim();
}
serialize(): SerializedAXNode {
const node: {[x in keyof SerializedAXNode]: any} = {
serialize(): accessibility.SerializedAXNode {
const node: {[x in keyof accessibility.SerializedAXNode]: any} = {
role: this._role
};
const userStringProperties: Array<keyof SerializedAXNode> = [
const userStringProperties: Array<keyof accessibility.SerializedAXNode> = [
'name',
'value',
'description',
@ -227,7 +173,7 @@ class AXNode {
continue;
node[userStringProperty] = this._payload[userStringProperty];
}
const booleanProperties: Array<keyof SerializedAXNode> = [
const booleanProperties: Array<keyof accessibility.SerializedAXNode> = [
'disabled',
'expanded',
'focused',
@ -246,7 +192,7 @@ class AXNode {
continue;
node[booleanProperty] = value;
}
const tristateProperties: Array<keyof SerializedAXNode> = [
const tristateProperties: Array<keyof accessibility.SerializedAXNode> = [
'checked',
'pressed',
];
@ -256,7 +202,7 @@ class AXNode {
const value = this._payload[tristateProperty];
node[tristateProperty] = value;
}
const numericalProperties: Array<keyof SerializedAXNode> = [
const numericalProperties: Array<keyof accessibility.SerializedAXNode> = [
'level'
];
for (const numericalProperty of numericalProperties) {
@ -264,7 +210,7 @@ class AXNode {
continue;
node[numericalProperty] = this._payload[numericalProperty];
}
const tokenProperties: Array<keyof SerializedAXNode> = [
const tokenProperties: Array<keyof accessibility.SerializedAXNode> = [
'autocomplete',
'haspopup',
'invalid',

View file

@ -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() {
@ -335,6 +335,10 @@ export class FFPage implements PageDelegate {
assert(false, 'Multiple isolated worlds are not implemented');
return handle;
}
async getAccessibilityTree() : Promise<accessibility.AXNode> {
return getAccessibilityTree(this._session);
}
}
export function normalizeWaitUntil(waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[]): frames.LifecycleEvent[] {

View file

@ -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<void>;
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
getAccessibilityTree(): Promise<accessibility.AXNode>;
}
type PageState = {
@ -100,6 +103,7 @@ export class Page extends EventEmitter {
private _pageBindings = new Map<string, Function>();
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();

View file

@ -33,6 +33,7 @@ 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';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
const BINDING_CALL_MESSAGE = '__playwright_binding_call__';
@ -461,6 +462,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<T>;
}
async getAccessibilityTree() : Promise<accessibility.AXNode> {
throw new Error('Not implemented');
}
}
function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject {

View file

@ -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() {
fdescribe('Accessibility', function() {
it('should work', async function({page}) {
await page.setContent(`
<head>

View file

@ -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);
}