From 2c9d464482116809b2c806358f37b9a260c32b3e Mon Sep 17 00:00:00 2001 From: Dhakshath Amin Date: Fri, 12 Jun 2026 20:27:23 +0530 Subject: [PATCH] fix(core): make getByRole match Android FQNs and export Role type --- packages/mobilewright-core/src/index.ts | 2 +- packages/mobilewright-core/src/locator.ts | 4 +- .../src/query-engine.test.ts | 66 +++++++++++++++++++ .../mobilewright-core/src/query-engine.ts | 33 +++++++--- packages/mobilewright-core/src/screen.ts | 3 +- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/mobilewright-core/src/index.ts b/packages/mobilewright-core/src/index.ts index 5075580..718ea8c 100644 --- a/packages/mobilewright-core/src/index.ts +++ b/packages/mobilewright-core/src/index.ts @@ -5,6 +5,6 @@ export { Device, type DeviceOptions } from './device.js'; export { MobileWebViewPage, MobileWebViewPage as Page } from './page.js'; export { MobileWebViewLocator, MobileWebViewLocator as WebLocator } from './web-locator.js'; export { expect, ExpectError, type ExpectOptions } from './expect.js'; -export { queryAll, type LocatorStrategy } from './query-engine.js'; +export { queryAll, ROLE_TYPE_MAP, type LocatorStrategy, type Role } from './query-engine.js'; export { sleep } from './sleep.js'; export type { HardwareButton } from '@mobilewright/protocol'; diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index e43eb01..8a2b7fe 100644 --- a/packages/mobilewright-core/src/locator.ts +++ b/packages/mobilewright-core/src/locator.ts @@ -1,6 +1,6 @@ import sharp from 'sharp'; import type { MobilewrightDriver, ViewNode, Bounds, SwipeDirection, ScreenSize } from '@mobilewright/protocol'; -import { queryAll, type LocatorStrategy } from './query-engine.js'; +import { queryAll, type LocatorStrategy, type Role } from './query-engine.js'; import { sleep } from './sleep.js'; import { runStep, type StepLocation } from './stackTrace.js'; @@ -76,7 +76,7 @@ export class Locator { return this.child({ kind: 'type', value: type }); } - getByRole(role: string, opts?: { name?: string | RegExp }): Locator { + getByRole(role: Role, opts?: { name?: string | RegExp }): Locator { return this.child({ kind: 'role', value: role, name: opts?.name }); } diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index b28d0f4..e5a662c 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -327,6 +327,72 @@ test.describe('React Native Android role mapping', () => { }); }); +test.describe('Fully-qualified role mapping', () => { + test('base android.widget.Button resolves to button role', () => { + const tree = [node({ type: 'android.widget.Button', label: 'OK' })]; + const results = queryAll(tree, { kind: 'role', value: 'button' }); + expect(results).toHaveLength(1); + }); + + test('Material FloatingActionButton resolves to button role', () => { + const tree = [node({ + type: 'com.google.android.material.floatingactionbutton.FloatingActionButton', + label: 'Create contact', + })]; + const results = queryAll(tree, { kind: 'role', value: 'button', name: 'Create contact' }); + expect(results).toHaveLength(1); + }); + + test('android.widget.EditText resolves to textfield role', () => { + const tree = [node({ type: 'android.widget.EditText', label: 'Email' })]; + const results = queryAll(tree, { kind: 'role', value: 'textfield' }); + expect(results).toHaveLength(1); + }); + + test('AppCompat TextView resolves to text role', () => { + const tree = [node({ + type: 'androidx.appcompat.widget.AppCompatTextView', + text: 'Hi', + })]; + const results = queryAll(tree, { kind: 'role', value: 'text' }); + expect(results).toHaveLength(1); + }); + + test('iOS short names continue to match unchanged', () => { + const tree = [ + node({ type: 'Button', label: 'OK' }), + node({ type: 'StaticText', text: 'Hi' }), + node({ type: 'TextField', label: 'Email' }), + ]; + expect(queryAll(tree, { kind: 'role', value: 'button' })).toHaveLength(1); + expect(queryAll(tree, { kind: 'role', value: 'text' })).toHaveLength(1); + expect(queryAll(tree, { kind: 'role', value: 'textfield' })).toHaveLength(1); + }); + + test('iOS un-stripped XCUIElementType prefix resolves to short role', () => { + // mobilecli normally strips this prefix, but defend against the case + // where a raw XCUITest dump reaches matchesRole. + const tree = [ + node({ type: 'XCUIElementTypeButton', label: 'OK' }), + node({ type: 'XCUIElementTypeStaticText', text: 'Hi' }), + node({ type: 'XCUIElementTypeTextField', label: 'Email' }), + ]; + expect(queryAll(tree, { kind: 'role', value: 'button' })).toHaveLength(1); + expect(queryAll(tree, { kind: 'role', value: 'text' })).toHaveLength(1); + expect(queryAll(tree, { kind: 'role', value: 'textfield' })).toHaveLength(1); + }); + + test('ReactViewGroup special-case still gated by clickable/accessible', () => { + const tree = [ + node({ type: 'ReactViewGroup', label: 'Submit', raw: { clickable: 'true' } }), + node({ type: 'ReactViewGroup', label: 'NotAButton', raw: { clickable: 'false' } }), + ]; + const results = queryAll(tree, { kind: 'role', value: 'button' }); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Submit'); + }); +}); + test.describe('placeholder strategy', () => { const tree: ViewNode[] = [ node({ type: 'TextField', placeholder: 'Enter email' }), diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index ae0da50..347133c 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -179,11 +179,11 @@ function matchesStrategy( } } -const ROLE_TYPE_MAP: Record = { - button: ['button', 'imagebutton'], - textfield: ['textfield', 'securetextfield', 'edittext', 'searchfield', 'reactedittext'], - text: ['statictext', 'textview', 'text', 'reacttextview'], - image: ['image', 'imageview', 'reactimageview'], +export const ROLE_TYPE_MAP = { + button: ['button', 'imagebutton', 'floatingactionbutton', 'materialbutton', 'appcompatbutton'], + textfield: ['textfield', 'securetextfield', 'searchfield', 'edittext', 'appcompatedittext', 'textinputedittext', 'reactedittext'], + text: ['statictext', 'textview', 'appcompattextview', 'materialtextview', 'text', 'reacttextview'], + image: ['image', 'imageview', 'appcompatimageview', 'shapeableimageview', 'reactimageview'], switch: ['switch', 'toggle'], checkbox: ['checkbox'], slider: ['slider', 'seekbar'], @@ -192,11 +192,28 @@ const ROLE_TYPE_MAP: Record = { tab: ['tab', 'tabbar'], link: ['link'], header: ['navigationbar', 'toolbar', 'header'], -}; +} as const satisfies Record; + +/** + * Semantic UI roles understood by mobilewright. Inspired by ARIA but adapted + * for native iOS / Android / RN widget vocabularies — not a 1:1 subset of W3C + * ARIA (e.g. mobilewright uses `textfield`, not ARIA's `textbox`). + */ +export type Role = keyof typeof ROLE_TYPE_MAP; function matchesRole(node: ViewNode, role: string): boolean { - const normalizedType = node.type.toLowerCase(); - const roleTypes = ROLE_TYPE_MAP[role.toLowerCase()]; + const rawType = node.type.toLowerCase(); + // Strip native package prefix so Android FQNs and the (rare) un-stripped iOS + // XCUIElementType prefix both match the short map keys: + // "android.widget.EditText" → "edittext" + // "com.google.…FloatingActionButton" → "floatingactionbutton" + // "XCUIElementTypeButton" → "button" + // "Button" (iOS, already short) → "button" + let normalizedType = rawType.includes('.') ? rawType.split('.').pop()! : rawType; + if (normalizedType.startsWith('xcuielementtype')) { + normalizedType = normalizedType.slice('xcuielementtype'.length); + } + const roleTypes: readonly string[] | undefined = (ROLE_TYPE_MAP as Record)[role.toLowerCase()]; // React Native's ReactViewGroup is used for everything — only treat it as a // button when the element is explicitly marked clickable or accessible. diff --git a/packages/mobilewright-core/src/screen.ts b/packages/mobilewright-core/src/screen.ts index f9638b1..b454ea5 100644 --- a/packages/mobilewright-core/src/screen.ts +++ b/packages/mobilewright-core/src/screen.ts @@ -11,6 +11,7 @@ import type { } from '@mobilewright/protocol'; import { Locator, type LocatorOptions, type StepFn } from './locator.js'; import { WebViewLocator } from './webview-locator.js'; +import type { Role } from './query-engine.js'; export interface GetByWebViewOptions { /** Match a web view whose native testId (accessibility id / resource-id) equals this. */ @@ -49,7 +50,7 @@ export class Screen { return this.root.getByType(type); } - getByRole(role: string, opts?: { name?: string | RegExp }): Locator { + getByRole(role: Role, opts?: { name?: string | RegExp }): Locator { return this.root.getByRole(role, opts); }