Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/src/guides/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions packages/mobilewright-core/src/query-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
23 changes: 18 additions & 5 deletions packages/mobilewright-core/src/query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
Expand Down
Loading