diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index 9412a19..dea13e6 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 24a5dd8..98ad380 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 5907563..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) { @@ -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';