-
-
Notifications
You must be signed in to change notification settings - Fork 10
feat(accessibility): add AccessibilityAuditService for on-device audits and element inspection #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
63d27c1
feat(accessibility-audit): implement accessibility audit service
navin772 2546971
enhance accessibility element handling and inspector interactions
navin772 c7296ec
Merge branch 'main' of https://github.com/appium/appium-ios-remotexpc…
navin772 f28b580
improve issue collection from completion callback
navin772 24b93a9
Merge branch 'main' into accessibility-audit-service
navin772 be66486
address review comments
navin772 452bc2a
Merge branch 'accessibility-audit-service' of https://github.com/navi…
navin772 451db9a
address review comments
navin772 41ab64c
Merge branch 'main' into accessibility-audit-service
navin772 d2c5ab1
Merge branch 'main' into accessibility-audit-service
harsha509 f28644d
Merge branch 'main' into accessibility-audit-service
navin772 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| /** | ||
| * The accessibility audit daemon wraps every value it returns in a recursive | ||
| * `{Value, ObjectType}` envelope. `ObjectType: "passthrough"` is a plain boxed | ||
| * value; any other `ObjectType` names a typed object (e.g. | ||
| * `AXAuditDeviceSetting_v1`) whose `Value` is a dictionary of fields. | ||
| */ | ||
| import {util} from '@appium/support'; | ||
|
|
||
| /** Key under which a typed (non-passthrough) object records its `ObjectType`. */ | ||
| export const AX_OBJECT_TYPE = '__axObjectType'; | ||
|
|
||
| /** A decoded typed object: its fields plus the {@link AX_OBJECT_TYPE} tag. */ | ||
| export type AxTypedObject = Record<string, unknown> & {[AX_OBJECT_TYPE]: string}; | ||
|
|
||
| function isEnvelope(value: unknown): value is {Value: unknown; ObjectType: string} { | ||
| return ( | ||
| util.isPlainObject(value) && | ||
| 'ObjectType' in value && | ||
| typeof (value as {ObjectType: unknown}).ObjectType === 'string' | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Recursively unwraps the daemon's serialized-object envelopes. | ||
| * | ||
| * @param value A value decoded from an NSKeyedArchiver reply. | ||
| */ | ||
| export function deserializeAxObject(value: unknown): unknown { | ||
| if (Array.isArray(value)) { | ||
| return value.map(deserializeAxObject); | ||
| } | ||
| if (!isEnvelope(value)) { | ||
| if (util.isPlainObject(value)) { | ||
| // A plain dictionary with no ObjectType: deserialize each field. | ||
| const out: Record<string, unknown> = {}; | ||
| for (const [key, inner] of Object.entries(value)) { | ||
| out[key] = deserializeAxObject(inner); | ||
| } | ||
| return out; | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| const inner = deserializeAxObject(value.Value); | ||
| if (value.ObjectType === 'passthrough') { | ||
| return inner; | ||
| } | ||
| // A typed object. Spread its fields (when it has them) and tag the type. | ||
| if (util.isPlainObject(inner)) { | ||
| return {...(inner as Record<string, unknown>), [AX_OBJECT_TYPE]: value.ObjectType}; | ||
| } | ||
| return {value: inner, [AX_OBJECT_TYPE]: value.ObjectType}; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| import {util} from '@appium/support'; | ||
|
|
||
| import {AX_OBJECT_TYPE} from './ax-deserialize.js'; | ||
|
|
||
| /** | ||
| * A handle to one element in the device's accessibility tree. | ||
| * | ||
| * `platformElement` is the daemon's opaque 20-byte identifier and is what makes | ||
| * the handle usable in later calls — it has to be sent back verbatim. | ||
| */ | ||
| export interface AxElement { | ||
| /** The daemon's opaque element identifier. */ | ||
| platformElement: Buffer; | ||
| /** The element's `accessibilityIdentifier`, when it has one. */ | ||
| accessibilityIdentifier?: string; | ||
| } | ||
|
|
||
| /** | ||
| * One attribute the daemon exposes for an element, e.g. `Label` or `Traits`. | ||
| * | ||
| * These are descriptors only — they carry no value. Reading a value takes a | ||
| * second call (`deviceElement:valueForAttribute:`) passing the element and the | ||
| * descriptor back, which is exactly what Xcode's Inspector does to fill each row | ||
| * of its panel. | ||
| */ | ||
| export interface AxElementAttribute { | ||
| /** Wire name, e.g. `TraitsHumanReadable`. Pass this back to read a value. */ | ||
| name: string; | ||
| /** Display name, e.g. `Traits`. */ | ||
| humanReadableName: string; | ||
| /** Whether the value can be written back. */ | ||
| settable: boolean; | ||
| /** Whether reading it performs an action rather than returning data. */ | ||
| performsAction: boolean; | ||
| /** Whether the daemon considers this internal/debug-only. */ | ||
| isInternal: boolean; | ||
| /** The daemon's value-type discriminator. */ | ||
| valueType?: number; | ||
| /** The raw descriptor, needed verbatim when asking for the value. */ | ||
| raw: Record<string, unknown>; | ||
| } | ||
|
|
||
| /** A titled group of attributes — `Basic`, `Actions`, `Element`, `Hierarchy`. */ | ||
| export interface AxInspectorSection { | ||
| /** Stable identifier, e.g. `Basic_v1`. */ | ||
| identifier: string; | ||
| /** Display title, e.g. `Basic`. */ | ||
| title: string; | ||
| /** The attributes in this section. */ | ||
| attributes: AxElementAttribute[]; | ||
| } | ||
|
|
||
| /** The inspector panel the device pushes when the focused element changes. */ | ||
| export interface AxInspectedElement { | ||
| /** What VoiceOver would announce, when the daemon provides it. */ | ||
| spokenDescription?: string; | ||
| /** The caption shown above the panel, when present. */ | ||
| caption?: string; | ||
| /** The panel's sections, in the order the device sent them. */ | ||
| sections: AxInspectorSection[]; | ||
| } | ||
|
|
||
| /** Recovers a `Buffer` from a decoded `NS.data` blob. */ | ||
| function toBuffer(value: unknown): Buffer | undefined { | ||
| if (Buffer.isBuffer(value)) { | ||
| return value; | ||
| } | ||
| if (util.isPlainObject(value)) { | ||
| // The archiver decodes NSData into an index-keyed object. | ||
| const bytes = Object.values(value as Record<string, unknown>).filter((b): b is number => typeof b === 'number'); | ||
| if (bytes.length > 0) { | ||
| return Buffer.from(bytes); | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Parses a deserialized `AXAuditElement_v1`. | ||
| * | ||
| * The `_v1` suffixes are the daemon's own wire keys, not our assumption. A | ||
| * future shape would carry different keys, so this returns `undefined` rather | ||
| * than misreading one. | ||
| */ | ||
| export function toAxElement(value: unknown): AxElement | undefined { | ||
| if (!util.isPlainObject(value)) { | ||
| return undefined; | ||
| } | ||
| const fields = value as Record<string, unknown>; | ||
| const platformValue = fields.PlatformElementValue_v1; | ||
| const container = util.isPlainObject(platformValue) | ||
| ? ((platformValue as Record<string, unknown>)['NS.data'] ?? platformValue) | ||
| : undefined; | ||
| const platformElement = toBuffer(container); | ||
| if (!platformElement) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| platformElement, | ||
| accessibilityIdentifier: | ||
| typeof fields.AccessibilityIdentifier_v1 === 'string' ? fields.AccessibilityIdentifier_v1 : undefined, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Rebuilds the serialized form the daemon expects when an element is passed | ||
| * back, matching what Xcode's Inspector sends. | ||
| */ | ||
| export function serializeAxElement(element: AxElement): Record<string, unknown> { | ||
| const value: Record<string, unknown> = { | ||
| PlatformElementValue_v1: {ObjectType: 'passthrough', Value: element.platformElement}, | ||
| }; | ||
| if (element.accessibilityIdentifier !== undefined) { | ||
| value.AccessibilityIdentifier_v1 = {ObjectType: 'passthrough', Value: element.accessibilityIdentifier}; | ||
| } | ||
| return { | ||
| ObjectType: 'AXAuditElement_v1', | ||
| Value: {ObjectType: 'passthrough', Value: value}, | ||
| }; | ||
| } | ||
|
|
||
| function toAttribute(value: unknown): AxElementAttribute | undefined { | ||
| if (!util.isPlainObject(value)) { | ||
| return undefined; | ||
| } | ||
| const fields = value as Record<string, unknown>; | ||
| const name = fields.AttributeNameValue_v1; | ||
| if (typeof name !== 'string') { | ||
| return undefined; | ||
| } | ||
| return { | ||
| name, | ||
| humanReadableName: typeof fields.HumanReadableNameValue_v1 === 'string' ? fields.HumanReadableNameValue_v1 : name, | ||
| settable: fields.SettableValue_v1 === true, | ||
| performsAction: fields.PerformsActionValue_v1 === true, | ||
| isInternal: fields.IsInternal_v1 === true, | ||
| valueType: typeof fields.ValueTypeValue_v1 === 'number' ? fields.ValueTypeValue_v1 : undefined, | ||
| raw: stripTag(fields), | ||
| }; | ||
| } | ||
|
|
||
| /** Drops the decoder's type tag so the object round-trips as the daemon sent it. */ | ||
| function stripTag(fields: Record<string, unknown>): Record<string, unknown> { | ||
| return Object.fromEntries(Object.entries(fields).filter(([key]) => key !== AX_OBJECT_TYPE)); | ||
| } | ||
|
|
||
| /** Rebuilds an attribute descriptor for the wire. */ | ||
| export function serializeAxAttribute(attribute: AxElementAttribute): Record<string, unknown> { | ||
| const value = Object.fromEntries( | ||
| Object.entries(attribute.raw).map(([key, inner]) => [key, {ObjectType: 'passthrough', Value: inner}]), | ||
| ); | ||
| return { | ||
| ObjectType: 'AXAuditElementAttribute_v1', | ||
| Value: {ObjectType: 'passthrough', Value: value}, | ||
| }; | ||
| } | ||
|
|
||
| /** Parses the payload of an inbound `hostInspectorCurrentElementChanged:`. */ | ||
| export function toInspectedElement(value: unknown): AxInspectedElement { | ||
| const fields = (util.isPlainObject(value) ? value : {}) as Record<string, unknown>; | ||
| const rawSections = Array.isArray(fields.InspectorSectionsValue_v1) ? fields.InspectorSectionsValue_v1 : []; | ||
| const sections: AxInspectorSection[] = []; | ||
| for (const rawSection of rawSections) { | ||
| if (!util.isPlainObject(rawSection)) { | ||
| continue; | ||
| } | ||
| const section = rawSection as Record<string, unknown>; | ||
| const rawAttributes = Array.isArray(section.ElementAttributesValue_v1) ? section.ElementAttributesValue_v1 : []; | ||
| sections.push({ | ||
| identifier: typeof section.IdentifierValue_v1 === 'string' ? section.IdentifierValue_v1 : '', | ||
| title: typeof section.TitleValue_v1 === 'string' ? section.TitleValue_v1 : '', | ||
| attributes: rawAttributes | ||
| .map(toAttribute) | ||
| .filter((attribute): attribute is AxElementAttribute => attribute !== undefined), | ||
| }); | ||
| } | ||
| return { | ||
| spokenDescription: | ||
| typeof fields.SpokenDescriptionValue_v1 === 'string' ? fields.SpokenDescriptionValue_v1 : undefined, | ||
| caption: typeof fields.CaptionTextValue_v1 === 'string' ? fields.CaptionTextValue_v1 : undefined, | ||
| sections, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import {PlistUID} from '../../../lib/plist/index.js'; | ||
|
|
||
| /** | ||
| * A point passed to the accessibility daemon, in normalized device coordinates | ||
| * (`0..1` across the screen's width and height). | ||
| * | ||
| * Carried as its own type because the daemon calls `CGPointValue` on the | ||
| * argument, so it has to arrive as an `NSValue` wrapping a `CGPoint` — an | ||
| * archived array, dictionary or bare double is rejected. Verified live on | ||
| * iOS 27.0: a double yields "Cannot get value with size 16. The type encoded as | ||
| * d is expected to be 8 bytes", and a dictionary yields | ||
| * "-[__NSDictionaryI CGPointValue]: unrecognized selector". | ||
| */ | ||
| export class AxPoint { | ||
| constructor( | ||
| readonly x: number, | ||
| readonly y: number, | ||
| ) {} | ||
| } | ||
|
|
||
| /** | ||
| * Builds the NSKeyedArchiver graph for an `NSValue` holding a `CGPoint`. | ||
| * | ||
| * `NS.special` discriminates the wrapped struct — 1 for a point. The device's | ||
| * own replies use 3 for rects (seen on `ElementRectValue_v1`), which is what | ||
| * corroborates the numbering. | ||
| */ | ||
| export function archiveAxPoint(point: AxPoint): Record<string, unknown> { | ||
| return { | ||
| $version: 100000, | ||
| $archiver: 'NSKeyedArchiver', | ||
| $top: {root: new PlistUID(1)}, | ||
| $objects: [ | ||
| '$null', | ||
| { | ||
| 'NS.special': 1, | ||
| 'NS.pointval': `{${point.x}, ${point.y}}`, | ||
| $class: new PlistUID(2), | ||
| }, | ||
| { | ||
| $classes: ['NSValue', 'NSObject'], | ||
| $classname: 'NSValue', | ||
| }, | ||
| ], | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.