Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mobilewright-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions packages/mobilewright-core/src/locator.ts
Original file line number Diff line number Diff line change
@@ -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';

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

Expand Down
66 changes: 66 additions & 0 deletions packages/mobilewright-core/src/query-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
33 changes: 25 additions & 8 deletions packages/mobilewright-core/src/query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,11 @@ function matchesStrategy(
}
}

const ROLE_TYPE_MAP: Record<string, string[]> = {
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'],
Expand All @@ -192,11 +192,28 @@ const ROLE_TYPE_MAP: Record<string, string[]> = {
tab: ['tab', 'tabbar'],
link: ['link'],
header: ['navigationbar', 'toolbar', 'header'],
};
} as const satisfies Record<string, readonly string[]>;

/**
* 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<string, readonly string[]>)[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.
Expand Down
3 changes: 2 additions & 1 deletion packages/mobilewright-core/src/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}

Expand Down