Skip to content

Commit 4b89ca2

Browse files
authored
feat(accessibility): add AccessibilityAuditService for on-device audits and element inspection (#293)
1 parent c8b703d commit 4b89ca2

10 files changed

Lines changed: 1591 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"test:afc": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/afc.spec.js\"",
4747
"test:all": "node --enable-source-maps --experimental-test-module-mocks --test --test-timeout=60000 \"build/test/unit/**/*.spec.js\" \"build/test/integration/**/*.spec.js\"",
4848
"test:app-service": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/app-service.spec.js\"",
49+
"test:accessibility-audit": "node --enable-source-maps --test --test-timeout=120000 \"build/test/integration/accessibility-audit.spec.js\"",
4950
"test:coredevice-device-info": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/device-info-coredevice.spec.js\"",
5051
"test:device-control": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/device-control.spec.js\"",
5152
"test:configuration": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/configuration.spec.js\"",

src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ export type {
5959
ListAppsOptions,
6060
} from './services/ios/app-service/index.js';
6161
export {PasteboardService} from './services/ios/pasteboard/index.js';
62+
export {AccessibilityAuditService} from './services/ios/accessibility-audit/index.js';
63+
export type {AxDeviceSetting} from './services/ios/accessibility-audit/index.js';
64+
export {AxAuditDtxTransport} from './services/ios/accessibility-audit/dtx-transport.js';
65+
export type {InvokeOptions as AxInvokeOptions} from './services/ios/accessibility-audit/dtx-transport.js';
66+
export {AX_OBJECT_TYPE, deserializeAxObject} from './services/ios/accessibility-audit/ax-deserialize.js';
67+
export {AxPoint} from './services/ios/accessibility-audit/ax-values.js';
68+
export {
69+
serializeAxAttribute,
70+
serializeAxElement,
71+
toAxElement,
72+
toInspectedElement,
73+
} from './services/ios/accessibility-audit/ax-element.js';
74+
export type {
75+
AxElement,
76+
AxElementAttribute,
77+
AxInspectedElement,
78+
AxInspectorSection,
79+
} from './services/ios/accessibility-audit/ax-element.js';
80+
export type {InspectOptions, RunAuditOptions, AxAuditIssue} from './services/ios/accessibility-audit/index.js';
6281
export {CoreDeviceInfoService} from './services/ios/device-info/index.js';
6382
export type {
6483
CoreDeviceAttributes,

src/services.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
resolveTunnelServicePorts,
55
} from './lib/tunnel/tunnel-service-resolver.js';
66
import type {DVTInstruments, SyslogService as SyslogServiceType, XCTestServices} from './lib/types.js';
7+
import {AccessibilityAuditService} from './services/ios/accessibility-audit/index.js';
78
import AfcService from './services/ios/afc/index.js';
89
import {AppService} from './services/ios/app-service/index.js';
910
import {type Service} from './services/ios/base-service.js';
@@ -139,6 +140,16 @@ export async function startCoreDeviceInfoService(udid: string): Promise<CoreDevi
139140
return new CoreDeviceInfoService(udid);
140141
}
141142

143+
/**
144+
* Start the accessibility audit service for the given device UDID — the DTX
145+
* backend behind Xcode's Accessibility Inspector. Exposes the device's
146+
* accessibility settings and audit catalogue.
147+
*/
148+
export async function startAccessibilityAuditService(udid: string): Promise<AccessibilityAuditService> {
149+
await requireCatalogService(udid, AccessibilityAuditService.RSD_SERVICE_NAME);
150+
return AccessibilityAuditService.start(udid);
151+
}
152+
142153
/**
143154
* Start the CoreDevice device-control service for the given device UDID.
144155
*/
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* The accessibility audit daemon wraps every value it returns in a recursive
3+
* `{Value, ObjectType}` envelope. `ObjectType: "passthrough"` is a plain boxed
4+
* value; any other `ObjectType` names a typed object (e.g.
5+
* `AXAuditDeviceSetting_v1`) whose `Value` is a dictionary of fields.
6+
*/
7+
import {util} from '@appium/support';
8+
9+
/** Key under which a typed (non-passthrough) object records its `ObjectType`. */
10+
export const AX_OBJECT_TYPE = '__axObjectType';
11+
12+
/** A decoded typed object: its fields plus the {@link AX_OBJECT_TYPE} tag. */
13+
export type AxTypedObject = Record<string, unknown> & {[AX_OBJECT_TYPE]: string};
14+
15+
function isEnvelope(value: unknown): value is {Value: unknown; ObjectType: string} {
16+
return (
17+
util.isPlainObject(value) &&
18+
'ObjectType' in value &&
19+
typeof (value as {ObjectType: unknown}).ObjectType === 'string'
20+
);
21+
}
22+
23+
/**
24+
* Recursively unwraps the daemon's serialized-object envelopes.
25+
*
26+
* @param value A value decoded from an NSKeyedArchiver reply.
27+
*/
28+
export function deserializeAxObject(value: unknown): unknown {
29+
if (Array.isArray(value)) {
30+
return value.map(deserializeAxObject);
31+
}
32+
if (!isEnvelope(value)) {
33+
if (util.isPlainObject(value)) {
34+
// A plain dictionary with no ObjectType: deserialize each field.
35+
const out: Record<string, unknown> = {};
36+
for (const [key, inner] of Object.entries(value)) {
37+
out[key] = deserializeAxObject(inner);
38+
}
39+
return out;
40+
}
41+
return value;
42+
}
43+
44+
const inner = deserializeAxObject(value.Value);
45+
if (value.ObjectType === 'passthrough') {
46+
return inner;
47+
}
48+
// A typed object. Spread its fields (when it has them) and tag the type.
49+
if (util.isPlainObject(inner)) {
50+
return {...(inner as Record<string, unknown>), [AX_OBJECT_TYPE]: value.ObjectType};
51+
}
52+
return {value: inner, [AX_OBJECT_TYPE]: value.ObjectType};
53+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {util} from '@appium/support';
2+
3+
import {AX_OBJECT_TYPE} from './ax-deserialize.js';
4+
5+
/**
6+
* A handle to one element in the device's accessibility tree.
7+
*
8+
* `platformElement` is the daemon's opaque 20-byte identifier and is what makes
9+
* the handle usable in later calls — it has to be sent back verbatim.
10+
*/
11+
export interface AxElement {
12+
/** The daemon's opaque element identifier. */
13+
platformElement: Buffer;
14+
/** The element's `accessibilityIdentifier`, when it has one. */
15+
accessibilityIdentifier?: string;
16+
}
17+
18+
/**
19+
* One attribute the daemon exposes for an element, e.g. `Label` or `Traits`.
20+
*
21+
* These are descriptors only — they carry no value. Reading a value takes a
22+
* second call (`deviceElement:valueForAttribute:`) passing the element and the
23+
* descriptor back, which is exactly what Xcode's Inspector does to fill each row
24+
* of its panel.
25+
*/
26+
export interface AxElementAttribute {
27+
/** Wire name, e.g. `TraitsHumanReadable`. Pass this back to read a value. */
28+
name: string;
29+
/** Display name, e.g. `Traits`. */
30+
humanReadableName: string;
31+
/** Whether the value can be written back. */
32+
settable: boolean;
33+
/** Whether reading it performs an action rather than returning data. */
34+
performsAction: boolean;
35+
/** Whether the daemon considers this internal/debug-only. */
36+
isInternal: boolean;
37+
/** The daemon's value-type discriminator. */
38+
valueType?: number;
39+
/** The raw descriptor, needed verbatim when asking for the value. */
40+
raw: Record<string, unknown>;
41+
}
42+
43+
/** A titled group of attributes — `Basic`, `Actions`, `Element`, `Hierarchy`. */
44+
export interface AxInspectorSection {
45+
/** Stable identifier, e.g. `Basic_v1`. */
46+
identifier: string;
47+
/** Display title, e.g. `Basic`. */
48+
title: string;
49+
/** The attributes in this section. */
50+
attributes: AxElementAttribute[];
51+
}
52+
53+
/** The inspector panel the device pushes when the focused element changes. */
54+
export interface AxInspectedElement {
55+
/** What VoiceOver would announce, when the daemon provides it. */
56+
spokenDescription?: string;
57+
/** The caption shown above the panel, when present. */
58+
caption?: string;
59+
/** The panel's sections, in the order the device sent them. */
60+
sections: AxInspectorSection[];
61+
}
62+
63+
/** Recovers a `Buffer` from a decoded `NS.data` blob. */
64+
function toBuffer(value: unknown): Buffer | undefined {
65+
if (Buffer.isBuffer(value)) {
66+
return value;
67+
}
68+
if (util.isPlainObject(value)) {
69+
// The archiver decodes NSData into an index-keyed object.
70+
const bytes = Object.values(value as Record<string, unknown>).filter((b): b is number => typeof b === 'number');
71+
if (bytes.length > 0) {
72+
return Buffer.from(bytes);
73+
}
74+
}
75+
return undefined;
76+
}
77+
78+
/**
79+
* Parses a deserialized `AXAuditElement_v1`.
80+
*
81+
* The `_v1` suffixes are the daemon's own wire keys, not our assumption. A
82+
* future shape would carry different keys, so this returns `undefined` rather
83+
* than misreading one.
84+
*/
85+
export function toAxElement(value: unknown): AxElement | undefined {
86+
if (!util.isPlainObject(value)) {
87+
return undefined;
88+
}
89+
const fields = value as Record<string, unknown>;
90+
const platformValue = fields.PlatformElementValue_v1;
91+
const container = util.isPlainObject(platformValue)
92+
? ((platformValue as Record<string, unknown>)['NS.data'] ?? platformValue)
93+
: undefined;
94+
const platformElement = toBuffer(container);
95+
if (!platformElement) {
96+
return undefined;
97+
}
98+
return {
99+
platformElement,
100+
accessibilityIdentifier:
101+
typeof fields.AccessibilityIdentifier_v1 === 'string' ? fields.AccessibilityIdentifier_v1 : undefined,
102+
};
103+
}
104+
105+
/**
106+
* Rebuilds the serialized form the daemon expects when an element is passed
107+
* back, matching what Xcode's Inspector sends.
108+
*/
109+
export function serializeAxElement(element: AxElement): Record<string, unknown> {
110+
const value: Record<string, unknown> = {
111+
PlatformElementValue_v1: {ObjectType: 'passthrough', Value: element.platformElement},
112+
};
113+
if (element.accessibilityIdentifier !== undefined) {
114+
value.AccessibilityIdentifier_v1 = {ObjectType: 'passthrough', Value: element.accessibilityIdentifier};
115+
}
116+
return {
117+
ObjectType: 'AXAuditElement_v1',
118+
Value: {ObjectType: 'passthrough', Value: value},
119+
};
120+
}
121+
122+
function toAttribute(value: unknown): AxElementAttribute | undefined {
123+
if (!util.isPlainObject(value)) {
124+
return undefined;
125+
}
126+
const fields = value as Record<string, unknown>;
127+
const name = fields.AttributeNameValue_v1;
128+
if (typeof name !== 'string') {
129+
return undefined;
130+
}
131+
return {
132+
name,
133+
humanReadableName: typeof fields.HumanReadableNameValue_v1 === 'string' ? fields.HumanReadableNameValue_v1 : name,
134+
settable: fields.SettableValue_v1 === true,
135+
performsAction: fields.PerformsActionValue_v1 === true,
136+
isInternal: fields.IsInternal_v1 === true,
137+
valueType: typeof fields.ValueTypeValue_v1 === 'number' ? fields.ValueTypeValue_v1 : undefined,
138+
raw: stripTag(fields),
139+
};
140+
}
141+
142+
/** Drops the decoder's type tag so the object round-trips as the daemon sent it. */
143+
function stripTag(fields: Record<string, unknown>): Record<string, unknown> {
144+
return Object.fromEntries(Object.entries(fields).filter(([key]) => key !== AX_OBJECT_TYPE));
145+
}
146+
147+
/** Rebuilds an attribute descriptor for the wire. */
148+
export function serializeAxAttribute(attribute: AxElementAttribute): Record<string, unknown> {
149+
const value = Object.fromEntries(
150+
Object.entries(attribute.raw).map(([key, inner]) => [key, {ObjectType: 'passthrough', Value: inner}]),
151+
);
152+
return {
153+
ObjectType: 'AXAuditElementAttribute_v1',
154+
Value: {ObjectType: 'passthrough', Value: value},
155+
};
156+
}
157+
158+
/** Parses the payload of an inbound `hostInspectorCurrentElementChanged:`. */
159+
export function toInspectedElement(value: unknown): AxInspectedElement {
160+
const fields = (util.isPlainObject(value) ? value : {}) as Record<string, unknown>;
161+
const rawSections = Array.isArray(fields.InspectorSectionsValue_v1) ? fields.InspectorSectionsValue_v1 : [];
162+
const sections: AxInspectorSection[] = [];
163+
for (const rawSection of rawSections) {
164+
if (!util.isPlainObject(rawSection)) {
165+
continue;
166+
}
167+
const section = rawSection as Record<string, unknown>;
168+
const rawAttributes = Array.isArray(section.ElementAttributesValue_v1) ? section.ElementAttributesValue_v1 : [];
169+
sections.push({
170+
identifier: typeof section.IdentifierValue_v1 === 'string' ? section.IdentifierValue_v1 : '',
171+
title: typeof section.TitleValue_v1 === 'string' ? section.TitleValue_v1 : '',
172+
attributes: rawAttributes
173+
.map(toAttribute)
174+
.filter((attribute): attribute is AxElementAttribute => attribute !== undefined),
175+
});
176+
}
177+
return {
178+
spokenDescription:
179+
typeof fields.SpokenDescriptionValue_v1 === 'string' ? fields.SpokenDescriptionValue_v1 : undefined,
180+
caption: typeof fields.CaptionTextValue_v1 === 'string' ? fields.CaptionTextValue_v1 : undefined,
181+
sections,
182+
};
183+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {PlistUID} from '../../../lib/plist/index.js';
2+
3+
/**
4+
* A point passed to the accessibility daemon, in normalized device coordinates
5+
* (`0..1` across the screen's width and height).
6+
*
7+
* Carried as its own type because the daemon calls `CGPointValue` on the
8+
* argument, so it has to arrive as an `NSValue` wrapping a `CGPoint` — an
9+
* archived array, dictionary or bare double is rejected. Verified live on
10+
* iOS 27.0: a double yields "Cannot get value with size 16. The type encoded as
11+
* d is expected to be 8 bytes", and a dictionary yields
12+
* "-[__NSDictionaryI CGPointValue]: unrecognized selector".
13+
*/
14+
export class AxPoint {
15+
constructor(
16+
readonly x: number,
17+
readonly y: number,
18+
) {}
19+
}
20+
21+
/**
22+
* Builds the NSKeyedArchiver graph for an `NSValue` holding a `CGPoint`.
23+
*
24+
* `NS.special` discriminates the wrapped struct — 1 for a point. The device's
25+
* own replies use 3 for rects (seen on `ElementRectValue_v1`), which is what
26+
* corroborates the numbering.
27+
*/
28+
export function archiveAxPoint(point: AxPoint): Record<string, unknown> {
29+
return {
30+
$version: 100000,
31+
$archiver: 'NSKeyedArchiver',
32+
$top: {root: new PlistUID(1)},
33+
$objects: [
34+
'$null',
35+
{
36+
'NS.special': 1,
37+
'NS.pointval': `{${point.x}, ${point.y}}`,
38+
$class: new PlistUID(2),
39+
},
40+
{
41+
$classes: ['NSValue', 'NSObject'],
42+
$classname: 'NSValue',
43+
},
44+
],
45+
};
46+
}

0 commit comments

Comments
 (0)