diff --git a/docs/src/guides/dialogs.md b/docs/src/guides/dialogs.md index 9df1b5f..4f8bdfc 100644 --- a/docs/src/guides/dialogs.md +++ b/docs/src/guides/dialogs.md @@ -45,6 +45,8 @@ Mobilewright reads the screen many times a second while an action or `expect()` On iOS, which button is positive comes from where it sits, following Apple's layout: with two buttons side by side, accept presses the right one and dismiss the left one. With stacked buttons, dismiss presses the bottom one. Accept presses the top one, or on a permission prompt the one just above Don't Allow (Allow While Using App). Use `dialog.tap(name)` when you need a specific button. +On Android, Jetpack Compose's Material3 `AlertDialog` is recognized too. It has none of the ids a classic `AlertDialog` has, so Mobilewright finds it by the pane title Material3 gives it, `Dialog`. Its first text is the title, and the rest is the message. Material3 lays out the dismiss button before the confirm button, so accept presses the last button and dismiss the first. The pane title is localized, so for now this only works on a device set to English. A plain Compose `Dialog { }` has no pane title, and isn't detected. + Use `screen.once('dialog', handler)` to handle only the next dialog, and `screen.off('dialog', handler)` to stop listening. ## Waiting for a dialog diff --git a/docs/src/guides/locators.md b/docs/src/guides/locators.md index dea13e6..55ac2ea 100644 --- a/docs/src/guides/locators.md +++ b/docs/src/guides/locators.md @@ -88,6 +88,7 @@ arrives as a clickable `View` with its caption on a child `TextView`. | Compose node | `getByRole()` | | --- | --- | | `android.view.View` | `button` — only when clickable | +| `android.view.View` | `alert` — only when its pane title is `Dialog`, as on a Material3 `AlertDialog` | A Compose `TextField` / `OutlinedTextField` is a real `android.widget.EditText` (`textfield`), but its floating `label = { Text("Email") }` is a child `TextView`. diff --git a/packages/mobilewright-core/src/dialog.test.ts b/packages/mobilewright-core/src/dialog.test.ts index 6e5b2fd..9e62976 100644 --- a/packages/mobilewright-core/src/dialog.test.ts +++ b/packages/mobilewright-core/src/dialog.test.ts @@ -476,3 +476,70 @@ test.describe('screen.waitForEvent(dialog)', () => { expect(events).toBe(1); }); }); + +// ─── Jetpack Compose (Material3) dialogs ───────────────────────── + +// Material3 draws its AlertDialog itself: no android:id/* ids, just a pane titled "Dialog" +// holding the title, the text, and clickable Views captioned by a child TextView. +function composeButton(caption: string, y: number): ViewNode { + return { ...node({ type: 'android.view.View', y }, [node({ type: 'android.widget.TextView', text: caption, y })]), raw: { clickable: true } }; +} + +function composeDialog(texts: string[], buttons: ViewNode[], paneTitle = 'Dialog'): ViewNode[] { + const pane = { ...node({ type: 'android.view.View' }, [ + ...texts.map((text) => node({ type: 'android.widget.TextView', text })), + ...buttons, + ]), raw: { paneTitle } }; + return [node({ identifier: 'android:id/content' }, [pane])]; +} + +test.describe('Compose dialogs', () => { + test('reads a Material3 confirm alert', async () => { + const tree = composeDialog(['Delete order?', 'This cannot be undone'], [composeButton('Cancel', 100), composeButton('Delete', 200)]); + const dialog = await firstDialogSeenBy(new Screen(createFakeDevice(tree).driver)); + expect(dialog.type()).toBe('confirm'); + expect(dialog.isSystem()).toBe(false); + expect(dialog.title()).toBe('Delete order?'); + expect(dialog.message()).toBe('This cannot be undone'); + expect(dialog.buttons()).toEqual(['Cancel', 'Delete']); + }); + + test('a Material3 alert with only a title has an empty message', async () => { + const tree = composeDialog(['You must be 18 or older'], [composeButton('Cancel', 100), composeButton('I\'m 18 or older', 200)]); + const dialog = await firstDialogSeenBy(new Screen(createFakeDevice(tree).driver)); + expect(dialog.title()).toBe('You must be 18 or older'); + expect(dialog.message()).toBe(''); + }); + + test('a single-button Material3 alert is of type alert', async () => { + const tree = composeDialog(['Saved'], [composeButton('OK', 200)]); + expect((await firstDialogSeenBy(new Screen(createFakeDevice(tree).driver))).type()).toBe('alert'); + }); + + test('accept presses the confirm button, which Material3 places last', async () => { + const device = createFakeDevice(composeDialog(['Delete order?'], [composeButton('Cancel', 100), composeButton('Delete', 200)])); + await (await firstDialogSeenBy(new Screen(device.driver))).accept(); + expect(device.taps).toEqual([centerOfButtonAt(200)]); + }); + + test('dismiss presses the dismiss button, which Material3 places first', async () => { + const device = createFakeDevice(composeDialog(['Delete order?'], [composeButton('Cancel', 100), composeButton('Delete', 200)])); + await (await firstDialogSeenBy(new Screen(device.driver))).dismiss(); + expect(device.taps).toEqual([centerOfButtonAt(100)]); + }); + + test('tap presses a Material3 button by its caption', async () => { + const device = createFakeDevice(composeDialog(['Delete order?'], [composeButton('Cancel', 100), composeButton('Delete', 200)])); + await (await firstDialogSeenBy(new Screen(device.driver))).tap('delete'); + expect(device.taps).toEqual([centerOfButtonAt(200)]); + }); + + test('a bottom sheet is not a dialog', async () => { + const tree = composeDialog(['Share'], [composeButton('Copy link', 100), composeButton('Cancel', 200)], 'Bottom Sheet'); + const screen = new Screen(createFakeDevice(tree).driver); + let seen = false; + screen.once('dialog', () => { seen = true; }); + await screen.getByText('anything').isVisible(); + expect(seen).toBe(false); + }); +}); diff --git a/packages/mobilewright-core/src/dialog.ts b/packages/mobilewright-core/src/dialog.ts index cb137ab..e92dc30 100644 --- a/packages/mobilewright-core/src/dialog.ts +++ b/packages/mobilewright-core/src/dialog.ts @@ -1,5 +1,5 @@ import type { MobilewrightDriver, ViewNode } from '@mobilewright/protocol'; -import { bareTypeName } from './query-engine.js'; +import { accessibleName, bareTypeName, isClickable, isComposeDialogPane } from './query-engine.js'; export type DialogType = 'alert' | 'confirm' | 'prompt' | 'permission'; @@ -28,8 +28,12 @@ function center(node: ViewNode): { x: number; y: number } { }; } +// A Compose button has no text of its own: its caption is on a child, which accessibleName reads. function textOf(node: ViewNode | undefined): string { - return node?.text ?? node?.label ?? ''; + if (!node) { + return ''; + } + return node.text || node.label || accessibleName(node); } function matchesId(node: ViewNode, pattern: RegExp | string): boolean { @@ -151,6 +155,23 @@ function findAndroidAlertDialog(nodes: ViewNode[], driver: MobilewrightDriver): return new Dialog(driver, type, title, message, buttons, positive, negative ?? positive, input); } +// A Material3 AlertDialog: title and text first, then dismissButton before confirmButton. +function findComposeAlertDialog(nodes: ViewNode[], driver: MobilewrightDriver): Dialog | undefined { + const pane = nodes.find(isComposeDialogPane); + if (!pane) { + return undefined; + } + const children = flatten(pane.children); + const buttons = children.filter(isClickable); + const insideButtons = new Set(buttons.flatMap((button) => flatten(button.children))); + const texts = children.filter((node) => bareTypeName(node.type) === 'textview' && !insideButtons.has(node)).map(textOf); + const input = children.find((node) => node.type.endsWith('EditText')); + const type: DialogType = input ? 'prompt' : buttons.length > 1 ? 'confirm' : 'alert'; + const accept = buttons.at(-1); + const dismiss = buttons.length > 1 ? buttons[0] : accept; + return new Dialog(driver, type, texts[0] ?? '', texts.slice(1).join('\n'), buttons, accept, dismiss, input); +} + function isSideBySide(buttons: ViewNode[]): boolean { return buttons.every((node) => node.bounds.y === buttons[0].bounds.y); } @@ -191,5 +212,5 @@ function findIosAlert(nodes: ViewNode[], driver: MobilewrightDriver): Dialog | u /** Detect an alert or permission dialog in a view tree. */ export function findDialog(roots: ViewNode[], driver: MobilewrightDriver): Dialog | undefined { const nodes = flatten(roots); - return findIosAlert(nodes, driver) ?? findAndroidPermissionDialog(nodes, driver) ?? findAndroidAlertDialog(nodes, driver); + return findIosAlert(nodes, driver) ?? findAndroidPermissionDialog(nodes, driver) ?? findAndroidAlertDialog(nodes, driver) ?? findComposeAlertDialog(nodes, driver); } diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index 98ad380..c86551d 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -827,3 +827,12 @@ test.describe('Jetpack Compose text field labels', () => { expect(results[0]).toBe(composeForm[1]); }); }); + +test.describe('Jetpack Compose alert role', () => { + const dialogPane = node({ type: 'android.view.View', raw: { paneTitle: 'Dialog' } }); + const bottomSheetPane = node({ type: 'android.view.View', raw: { paneTitle: 'Bottom Sheet' } }); + + test('a Material3 dialog pane matches the alert role', () => { + expect(queryAll([dialogPane, bottomSheetPane], { kind: 'role', value: 'alert' })).toEqual([dialogPane]); + }); +}); diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index d1024be..2e06ca3 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -246,7 +246,7 @@ const NAME_FROM_CONTENT_ROLES = new Set(['button', 'link', 'tab', 'header', 'swi * 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 { +export function accessibleName(node: ViewNode): string { const ownName = node.label || node.text; if (ownName) { return ownName; @@ -269,10 +269,20 @@ function labelOf(node: ViewNode): string | undefined { } /** mobilecli reports clickable as a boolean, Appium-style drivers as a string. */ -function isClickable(node: ViewNode): boolean { +export function isClickable(node: ViewNode): boolean { return node.raw?.['clickable'] === true || node.raw?.['clickable'] === 'true'; } +/** + * Material3 marks an AlertDialog's container with the pane title "Dialog"; it is the only + * sign of a Compose dialog, which has none of android.app.AlertDialog's ids. + */ +export function isComposeDialogPane(node: ViewNode): boolean { + // ponytail: Material3's English string, so other locales go undetected; match the localized + // m3c_dialog string if that matters + return node.raw?.['paneTitle'] === 'Dialog'; +} + 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 @@ -289,6 +299,10 @@ export function matchesRole(node: ViewNode, role: string): boolean { return false; } + if (role.toLowerCase() === 'alert' && isComposeDialogPane(node)) { + return true; + } + // 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') {