From ac6011eec3d8e94c3b0e34609002e573cc022ccf Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 16 Oct 2026 13:08:13 +0200 Subject: [PATCH 1/5] feat: getByRole('button') matches jetpack compose buttons --- .../src/query-engine.test.ts | 47 +++++++++++++++++++ .../mobilewright-core/src/query-engine.ts | 29 +++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index 40e7376e..67fb1604 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -720,3 +720,50 @@ 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 button name regex matches the text of its children', () => { + const results = queryAll(composeTree, { kind: 'role', value: 'button', name: /order/i }); + expect(results).toHaveLength(1); + }); +}); diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index 8a198d62..d18f3c48 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -158,7 +158,7 @@ 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 = accessibleName(node); if (strategy.name instanceof RegExp) { return strategy.name.test(nodeLabel); } @@ -234,6 +234,27 @@ export function bareTypeName(type: string): string { : afterPackage; } +/** + * The name getByRole 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(' '); +} + +/** 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 +271,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); } From 0cc3cc41566e1c9bd221fbb11731469067874937 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 16 Oct 2026 20:57:26 +0200 Subject: [PATCH 2/5] fix: only name-from-content roles take their name from descendants --- .../mobilewright-core/src/query-engine.test.ts | 17 +++++++++++++++++ packages/mobilewright-core/src/query-engine.ts | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index 67fb1604..24a5dd8f 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -762,6 +762,23 @@ test.describe('Jetpack Compose role mapping', () => { 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); diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index d18f3c48..59075637 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -158,7 +158,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 = accessibleName(node); + const nodeLabel = NAME_FROM_CONTENT_ROLES.has(strategy.value.toLowerCase()) + ? accessibleName(node) + : node.label ?? node.text ?? ''; if (strategy.name instanceof RegExp) { return strategy.name.test(nodeLabel); } @@ -234,8 +236,11 @@ 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 getByRole matches against: the node's own label, then its own text, + * 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. */ From 0adff87e8ea62e3b25b1362997902617e615ca04 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 16 Oct 2026 21:00:55 +0200 Subject: [PATCH 3/5] docs: compose buttons and accessible names in the locators guide --- docs/src/guides/locators.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index 4a4a5772..46e9b559 100644 --- a/docs/src/guides/locators.md +++ b/docs/src/guides/locators.md @@ -79,6 +79,16 @@ 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 (needs a mobilecli release that reports `clickable`) | + ## iOS class → role | Native class | `getByRole()` | @@ -108,6 +118,19 @@ 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. + ## Notes and known gaps - **Classes with no role.** Anything not listed above has no role mapping — From cd7d8366cd3c90c70aada1ebf9d611e02051ff67 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 17 Oct 2026 22:04:59 +0200 Subject: [PATCH 4/5] docs: drop the mobilecli release note for clickable --- docs/src/guides/locators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index 46e9b559..9412a19a 100644 --- a/docs/src/guides/locators.md +++ b/docs/src/guides/locators.md @@ -87,7 +87,7 @@ arrives as a clickable `View` with its caption on a child `TextView`. | Compose node | `getByRole()` | | --- | --- | -| `android.view.View` | `button` — only when clickable (needs a mobilecli release that reports `clickable`) | +| `android.view.View` | `button` — only when clickable | ## iOS class → role From 08ea2e010320036bd70d737f898a2201b6dff872 Mon Sep 17 00:00:00 2001 From: Gil Megidish Date: Sun, 18 Oct 2026 11:40:13 +0200 Subject: [PATCH 5/5] feat: getByLabel finds jetpack compose text fields by their floating label (#417) --- docs/src/guides/locators.md | 11 +++++ .../src/query-engine.test.ts | 43 +++++++++++++++++++ .../mobilewright-core/src/query-engine.ts | 23 +++++++--- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index 9412a19a..dea13e6a 100644 --- a/docs/src/guides/locators.md +++ b/docs/src/guides/locators.md @@ -89,6 +89,16 @@ arrives as a clickable `View` with its caption on a child `TextView`. | --- | --- | | `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()` | @@ -130,6 +140,7 @@ 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 diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index 24a5dd8f..98ad3801 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -784,3 +784,46 @@ test.describe('Jetpack Compose role mapping', () => { 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 59075637..d1024be1 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) { @@ -160,7 +162,7 @@ function matchesStrategy( // displayed text, mirroring ARIA accessible-name computation. const nodeLabel = NAME_FROM_CONTENT_ROLES.has(strategy.value.toLowerCase()) ? accessibleName(node) - : node.label ?? node.text ?? ''; + : labelOf(node) ?? node.text ?? ''; if (strategy.name instanceof RegExp) { return strategy.name.test(nodeLabel); } @@ -255,6 +257,17 @@ function accessibleName(node: ViewNode): string { .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';