diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index 4a4a577..dea13e6 100644 --- a/docs/src/guides/locators.md +++ b/docs/src/guides/locators.md @@ -79,6 +79,26 @@ of the collapsed framework class above — these are also recognized: | `…scroll.ReactScrollView` | `list` | | `…view.ReactViewGroup` | `button` — only when `clickable="true"` or `accessible="true"` | +### Jetpack Compose (Android) + +Compose reports most of its widgets as a plain `android.view.View`. It only reports +`android.widget.Button` for a button with no children, so a `Button { Text("Save") }` +arrives as a clickable `View` with its caption on a child `TextView`. + +| Compose node | `getByRole()` | +| --- | --- | +| `android.view.View` | `button` — only when clickable | + +A Compose `TextField` / `OutlinedTextField` is a real `android.widget.EditText` +(`textfield`), but its floating `label = { Text("Email") }` is a child `TextView`. +A text field without a label of its own is labelled by that child, so both of these +find it: + +```typescript +await screen.getByLabel('Email').fill('glorpax@milliways.space'); +await expect(screen.getByRole('textfield', { name: 'Email' })).toHaveValue('glorpax@milliways.space'); +``` + ## iOS class → role | Native class | `getByRole()` | @@ -108,6 +128,20 @@ of the collapsed framework class above — these are also recognized: | `XCUIElementTypeLink` | `link` | | `XCUIElementTypeNavigationBar` | `header` | +## Accessible names + +`getByRole('button', { name })` matches the element's own label, then its own text. +Roles that take their name from their content — `button`, `link`, `tab`, `header`, +`switch`, `checkbox` and `radio`, as in ARIA — then fall back to the text of their +children. That is how a Compose button is named by its caption: + +```typescript +await screen.getByRole('button', { name: 'Save' }).tap(); +``` + +Other roles are never named by their children, so a `list` isn't named by its items. +The one exception is a `textfield`, which is named by its floating label (see above). + ## Notes and known gaps - **Classes with no role.** Anything not listed above has no role mapping — diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index 40e7376..98ad380 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -720,3 +720,110 @@ test.describe('filter strategy on flat hierarchy (bounds-based)', () => { expect(results[0].label).toBe('Row 2'); }); }); + +test.describe('Jetpack Compose role mapping', () => { + // Compose reports Button { Text("New Order") } as a clickable View with the caption on a child + const composeTree: ViewNode[] = [ + node({ + type: 'android.view.View', + raw: { clickable: true }, + children: [node({ type: 'android.widget.TextView', text: 'New Order' })], + }), + node({ + type: 'android.view.View', + raw: { clickable: true }, + children: [node({ type: 'android.view.View', label: 'Account' })], + }), + node({ + type: 'android.view.View', + children: [node({ type: 'android.widget.TextView', text: 'Welcome to Milliways' })], + }), + ]; + + test('a clickable View matches the button role', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button' }); + expect(results).toHaveLength(2); + }); + + test('a View that is not clickable does not match the button role', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button', name: 'Welcome to Milliways' }); + expect(results).toHaveLength(0); + }); + + test('a button is named by the text of its children', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button', name: 'New Order' }); + expect(results).toHaveLength(1); + expect(results[0]).toBe(composeTree[0]); + }); + + test('a button is named by the label of its children', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button', name: 'Account' }); + expect(results).toHaveLength(1); + expect(results[0]).toBe(composeTree[1]); + }); + + test('a list is not named by the text of its children', () => { + const list = node({ + type: 'android.widget.ListView', + children: [node({ type: 'android.widget.TextView', text: 'Coffee' })], + }); + expect(queryAll([list], { kind: 'role', value: 'list', name: 'Coffee' })).toHaveLength(0); + }); + + test('a list is still named by its own label', () => { + const list = node({ + type: 'android.widget.ListView', + label: 'Menu', + children: [node({ type: 'android.widget.TextView', text: 'Coffee' })], + }); + expect(queryAll([list], { kind: 'role', value: 'list', name: 'Menu' })).toEqual([list]); + }); + + test('a button name regex matches the text of its children', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button', name: /order/i }); + expect(results).toHaveLength(1); + }); +}); + +test.describe('Jetpack Compose text field labels', () => { + // Compose reports a TextField's floating label as a child of the EditText + const composeForm: ViewNode[] = [ + node({ + type: 'android.widget.EditText', + text: 'Glorpax', + children: [node({ type: 'android.widget.TextView', text: 'Display name' })], + }), + node({ + type: 'android.widget.EditText', + text: '', + children: [node({ type: 'android.widget.TextView', text: 'Phone' })], + }), + node({ + type: 'android.view.View', + children: [node({ type: 'android.widget.TextView', text: 'Profile' })], + }), + ]; + + test('getByLabel finds a text field by its floating label', () => { + const results = queryAll(composeForm, { kind: 'label', value: 'Display name' }); + expect(results).toHaveLength(1); + expect(results[0]).toBe(composeForm[0]); + }); + + test('getByLabel with exact: false finds a text field by part of its floating label', () => { + const results = queryAll(composeForm, { kind: 'label', value: 'display', exact: false }); + expect(results).toHaveLength(1); + expect(results[0]).toBe(composeForm[0]); + }); + + test('a floating label does not label a node that is not a text field', () => { + const results = queryAll(composeForm, { kind: 'label', value: 'Profile' }); + expect(results).toHaveLength(0); + }); + + test('an empty text field is named by its floating label', () => { + const results = queryAll(composeForm, { kind: 'role', value: 'textfield', name: 'Phone' }); + expect(results).toHaveLength(1); + expect(results[0]).toBe(composeForm[1]); + }); +}); diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index 8a198d6..d1024be 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -115,11 +115,13 @@ function matchesStrategy( case 'root': return true; - case 'label': - if (!node.label) return false; + case 'label': { + const label = labelOf(node); + if (!label) return false; return strategy.exact === false - ? node.label.toLowerCase().includes(strategy.value.toLowerCase()) - : node.label === strategy.value; + ? label.toLowerCase().includes(strategy.value.toLowerCase()) + : label === strategy.value; + } case 'testId': if (node.key === strategy.value) { @@ -158,7 +160,9 @@ function matchesStrategy( // Accessible-name order (label before text) is intentionally the reverse of // the `text` locator above: an explicit label/accessibilityLabel outranks // displayed text, mirroring ARIA accessible-name computation. - const nodeLabel = node.label ?? node.text ?? ''; + const nodeLabel = NAME_FROM_CONTENT_ROLES.has(strategy.value.toLowerCase()) + ? accessibleName(node) + : labelOf(node) ?? node.text ?? ''; if (strategy.name instanceof RegExp) { return strategy.name.test(nodeLabel); } @@ -234,6 +238,41 @@ export function bareTypeName(type: string): string { : afterPackage; } +/** Roles that ARIA lets take their name from their content; a list or a text field never does. */ +const NAME_FROM_CONTENT_ROLES = new Set(['button', 'link', 'tab', 'header', 'switch', 'checkbox', 'radio']); + +/** + * The name a name-from-content role matches against: the node's own label, then its own text, + * then the text of its descendants. The last step mirrors ARIA's name-from-content, + * and covers Jetpack Compose buttons, which keep their caption on a child node. + */ +function accessibleName(node: ViewNode): string { + const ownName = node.label || node.text; + if (ownName) { + return ownName; + } + return node.children + .map(accessibleName) + .filter((name) => name !== '') + .join(' '); +} + +/** + * A node's label. A text field without one is labelled by its children, which is + * where a Jetpack Compose TextField keeps its floating label. + */ +function labelOf(node: ViewNode): string | undefined { + if (node.label || !matchesRole(node, 'textfield')) { + return node.label; + } + return node.children.map(accessibleName).filter((name) => name !== '').join(' ') || undefined; +} + +/** mobilecli reports clickable as a boolean, Appium-style drivers as a string. */ +function isClickable(node: ViewNode): boolean { + return node.raw?.['clickable'] === true || node.raw?.['clickable'] === 'true'; +} + export function matchesRole(node: ViewNode, role: string): boolean { const normalizedType = bareTypeName(node.type); // ROLE_TYPE_MAP is `as const`, so its keys are a closed union; widen for the @@ -250,6 +289,12 @@ export function matchesRole(node: ViewNode, role: string): boolean { return false; } + // Jetpack Compose only reports android.widget.Button for a button without + // children, so a Button { Text(...) } shows up as a clickable plain View. + if (normalizedType === 'view' && role.toLowerCase() === 'button') { + return isClickable(node); + } + if (roleTypes) { return roleTypes.includes(normalizedType); }