Skip to content
Open
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
28 changes: 21 additions & 7 deletions docs/src/guides/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ screen.on('dialog', async (dialog) => {
await screen.getByText('Find nearby stores').tap(); // the location prompt is accepted on the way
```

:::note
Dialogs are supported on Android. iOS support is coming.
:::

## How it works

Mobilewright reads the screen many times a second while an action or `expect()` is waiting. With a listener registered, each of those reads also checks for a dialog. A dialog that covers the element you're tapping is handled within one poll, well inside the action timeout.
Expand All @@ -34,28 +30,46 @@ Mobilewright reads the screen many times a second while an action or `expect()`
| Method | Does |
|---|---|
| `dialog.accept(promptText?)` | Presses the positive button: OK, Allow, While using the app. For a prompt, types `promptText` into its text field first. |
| `dialog.dismiss()` | Presses the negative button: Cancel, Don't allow. On a single-button alert, presses that button. |
| `dialog.dismiss()` | Presses the negative button: Cancel, Don't Allow. On a single-button alert, presses that button. |
| `dialog.tap(name)` | Presses the button with this caption, ignoring case. |

## Reading

| Method | Returns |
|---|---|
| `dialog.type()` | `'alert'`, `'confirm'`, `'prompt'` or `'permission'` |
| `dialog.isSystem()` | `true` for prompts shown by the OS, like runtime permission requests |
| `dialog.isSystem()` | `true` for prompts shown by the OS, like runtime permission requests. On iOS this is recognized by the app name in curly quotes in the title (“MyApp” Would Like to…). |
| `dialog.title()` | The title, empty when there is none |
| `dialog.message()` | The message text |
| `dialog.buttons()` | The button captions, in on-screen order |

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.

Use `screen.once('dialog', handler)` to handle only the next dialog, and `screen.off('dialog', handler)` to stop listening.

## Waiting for a dialog

To assert that an action opens a dialog, start waiting before the action, like Playwright's `page.waitForEvent('dialog')`:

```typescript
const dialogPromise = screen.waitForEvent('dialog');
await screen.getByText('Scan a code').tap();
const dialog = await dialogPromise;
expect(dialog.type()).toBe('permission');
await dialog.dismiss();
```

The dialog is left for you to answer. Pass a predicate, or `{ predicate, timeout }`, to wait for a specific one. The default timeout is the action timeout. A device doesn't push dialog events the way a browser does, so `waitForEvent` polls the screen until a dialog shows up.

## Resetting permissions between runs

Once a permission is granted, or denied twice, Android stops asking. Clear the app's data before the test to get the prompt back:
Once a permission is granted, or denied twice, Android stops asking. On Android, clear the app's data before the test to get the prompt back:

```typescript
test.beforeEach(async ({ device }) => {
await device.clearAppData('com.example.app'); // also resets runtime permissions
await device.launchApp('com.example.app');
});
```

On an iOS simulator, `clearAppData()` does not reset permissions. Run `xcrun simctl privacy <udid> reset all <bundle id>` to reset camera, location and the other privacy permissions; notifications only reset when you reinstall the app.
63 changes: 40 additions & 23 deletions e2e/src/android/dialogs.test.ts → e2e/src/dialogs/dialogs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { Device, Screen, Dialog } from 'mobilewright';

const PLAYGROUND_APP = 'com.mobilenext.playground';

test.use({ platform: 'android' });

// ─── Helpers ─────────────────────────────────────────────────────

// clearAppData also resets runtime permissions, so permission prompts show up on every run.
// On Android, clearAppData also resets runtime permissions, so permission prompts show up on every run.
// On an iOS simulator it does not: reset them with `xcrun simctl privacy <udid> reset all <bundle>`,
// and reinstall the app to reset notifications.
async function openPermissionsAndAlertsWithFreshPermissions(device: Device, screen: Screen): Promise<void> {
await device.terminateApp(PLAYGROUND_APP).catch(() => {});
await device.clearAppData(PLAYGROUND_APP);
Expand All @@ -16,15 +16,22 @@ async function openPermissionsAndAlertsWithFreshPermissions(device: Device, scre
}

function alertResult(screen: Screen) {
return screen.getByLabel('alert_result');
return screen.getByTestId('alert_result');
}

function deniedStatus(platform: 'ios' | 'android' | undefined): string {
return platform === 'ios' ? 'Denied' : 'Not Granted';
}

function permissionStatus(screen: Screen, permission: 'camera' | 'location' | 'notifications') {
return screen.getByLabel(`${permission}_permission_status`);
return screen.getByTestId(`${permission}_permission_status`);
}

async function pressButton(screen: Screen, testLabel: string): Promise<void> {
await screen.getByLabel(testLabel).tap();
async function pressButton(screen: Screen, testId: string): Promise<void> {
// the alert buttons sit below the fold on smaller screens, and iOS only lists rendered rows
const button = screen.getByTestId(testId);
await button.scrollIntoViewIfNeeded();
await button.tap();
}

function recordDialogs(screen: Screen, respond: (dialog: Dialog) => Promise<void>): Dialog[] {
Expand All @@ -36,17 +43,6 @@ function recordDialogs(screen: Screen, respond: (dialog: Dialog) => Promise<void
return seen;
}

// Dialogs are spotted while the screen is polled; poll until `count` dialogs were seen.
async function waitUntilDialogsSeen(screen: Screen, dialogs: Dialog[], count: number): Promise<void> {
const deadline = Date.now() + 5000;
while (dialogs.length < count) {
if (Date.now() > deadline) {
throw new Error(`expected ${count} dialog(s), saw ${dialogs.length}`);
}
await screen.viewTree();
}
}

test.beforeEach(async ({ device, screen }) => {
await openPermissionsAndAlertsWithFreshPermissions(device, screen);
});
Expand Down Expand Up @@ -100,16 +96,37 @@ test('accepting the location prompt grants location', async ({ screen }) => {
expect(dialogs[0].isSystem()).toBe(true);
});

test('dismissing the camera prompt denies the camera', async ({ screen }) => {
const dialogs = recordDialogs(screen, (dialog) => dialog.dismiss());
test('dismissing the camera prompt denies the camera', async ({ screen, platform }) => {
const dialogPromise = screen.waitForEvent('dialog');
await pressButton(screen, 'request_camera_permission_button');
await waitUntilDialogsSeen(screen, dialogs, 1);
await expect(screen.getByText('Don’t allow')).toBeHidden();
await expect(permissionStatus(screen, 'camera')).toHaveText('Not Granted');
const dialog = await dialogPromise;
expect(dialog.type()).toBe('permission');
await dialog.dismiss();
await expect(screen.getByText(/Don’t allow/i)).toBeHidden();
await expect(permissionStatus(screen, 'camera')).toHaveText(deniedStatus(platform));
});

test('accepting the notifications prompt grants notifications', async ({ screen }) => {
recordDialogs(screen, (dialog) => dialog.accept());
await pressButton(screen, 'request_notifications_permission_button');
await expect(permissionStatus(screen, 'notifications')).toHaveText('Granted');
});

// ─── Sheets are not dialogs ──────────────────────────────────────

test('an action sheet is left alone, and the test picks an option with a locator', async ({ screen }) => {
const dialogs = recordDialogs(screen, (dialog) => dialog.accept());
await pressButton(screen, 'show_action_sheet_button');
await screen.getByText(/^red$/i).tap();
await expect(alertResult(screen)).toHaveText('Red');
expect(dialogs).toHaveLength(0);
});

test('a bottom sheet is left alone, and the test picks an option with a locator', async ({ screen, platform }) => {
test.skip(platform === 'ios', 'bottom sheets are an Android control');
const dialogs = recordDialogs(screen, (dialog) => dialog.accept());
await pressButton(screen, 'show_bottom_sheet_button');
await screen.getByText(/^red$/i).tap();
await expect(alertResult(screen)).toHaveText('Red');
expect(dialogs).toHaveLength(0);
});
183 changes: 179 additions & 4 deletions packages/mobilewright-core/src/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type FakeDevice = {
show: (tree: ViewNode[]) => void;
};

type NodeSpec = { type?: string; identifier?: string; text?: string; label?: string; y?: number };
type NodeSpec = { type?: string; identifier?: string; text?: string; label?: string; x?: number; y?: number };

// ─── View-tree builders, shaped like mobilecli's Android dump ───────────

Expand All @@ -24,7 +24,7 @@ function node(spec: NodeSpec, children: ViewNode[] = []): ViewNode {
label: spec.label,
isVisible: true,
isEnabled: true,
bounds: { x: 0, y: spec.y ?? 0, width: 100, height: 50 },
bounds: { x: spec.x ?? 0, y: spec.y ?? 0, width: 100, height: 50 },
children,
};
}
Expand Down Expand Up @@ -88,8 +88,8 @@ function locationPermissionDialog(): ViewNode[] {
])];
}

function centerOfButtonAt(y: number): [number, number] {
return [50, y + 25];
function centerOfButtonAt(y: number, x = 0): [number, number] {
return [x + 50, y + 25];
}

// ─── Fake device: shows a tree; a tap on anything but the text field closes a dialog ───
Expand Down Expand Up @@ -278,3 +278,178 @@ test.describe('screen.on(dialog)', () => {
expect(events).toBe(0);
});
});

// ─── iOS: view-tree builders, shaped like mobilecli's iOS dump ───────────

function iosButton(label: string, x: number, y: number): ViewNode {
return node({ type: 'Button', label, x, y });
}

function iosAlert(title: string, message: string, children: ViewNode[]): ViewNode[] {
return [node({ type: 'Application', label: 'Playground' }, [
node({ type: 'Alert', label: title }, [
node({ type: 'StaticText', label: title }),
node({ type: 'StaticText', label: message }),
...children,
]),
])];
}

function iosConfirmAlert(): ViewNode[] {
return iosAlert('Confirm Alert', 'Do you want to continue?', [iosButton('Cancel', 0, 300), iosButton('OK', 150, 300)]);
}

function iosThreeButtonAlert(): ViewNode[] {
return iosAlert('Three Button Alert', 'Pick one of three options', [
iosButton('Yes', 0, 100), iosButton('No', 0, 200), iosButton('Later', 0, 300),
]);
}

function iosPromptAlert(okButtonY = 300): ViewNode[] {
return iosAlert('Prompt Alert', 'What is your name?', [
node({ type: 'TextField', y: TEXT_FIELD_Y }),
iosButton('Cancel', 0, okButtonY),
iosButton('OK', 150, okButtonY),
]);
}

function iosCameraPermissionAlert(): ViewNode[] {
return iosAlert('“Playground” would like to access the Camera.', 'Used to scan codes.', [
iosButton('Don’t Allow', 0, 300), iosButton('Allow', 150, 300),
]);
}

function iosLocationPermissionAlert(): ViewNode[] {
return iosAlert('Allow “Playground” to use your location?', 'Used to show your current location on a map.', [
iosButton('Allow Once', 0, 100), iosButton('Allow While Using App', 0, 200), iosButton('Don’t Allow', 0, 300),
]);
}

function iosActionSheet(): ViewNode[] {
return [node({ type: 'Application', label: 'Playground' }, [
node({ type: 'Other', label: 'PopoverDismissRegion' }),
node({ type: 'Sheet', label: 'Choose a Color' }, [
node({ type: 'StaticText', label: 'Choose a Color' }),
iosButton('Red', 0, 100), iosButton('Green', 0, 150), iosButton('Blue', 0, 200),
]),
])];
}

test.describe('iOS dialogs', () => {
test('an action sheet is not reported', async () => {
const device = createFakeDevice(iosActionSheet());
const screen = new Screen(device.driver);
let events = 0;
screen.on('dialog', () => { events++; });
expect(await screen.getByText('Red').isVisible()).toBe(true);
expect(events).toBe(0);
});

test('reads an app confirm alert', async () => {
const dialog = await firstDialogSeenBy(new Screen(createFakeDevice(iosConfirmAlert()).driver));
expect(dialog.type()).toBe('confirm');
expect(dialog.isSystem()).toBe(false);
expect(dialog.title()).toBe('Confirm Alert');
expect(dialog.message()).toBe('Do you want to continue?');
expect(dialog.buttons()).toEqual(['Cancel', 'OK']);
});

test('side-by-side buttons: accept presses the right one, dismiss the left one', async () => {
const accepting = createFakeDevice(iosConfirmAlert());
await (await firstDialogSeenBy(new Screen(accepting.driver))).accept();
expect(accepting.taps).toEqual([centerOfButtonAt(300, 150)]);

const dismissing = createFakeDevice(iosConfirmAlert());
await (await firstDialogSeenBy(new Screen(dismissing.driver))).dismiss();
expect(dismissing.taps).toEqual([centerOfButtonAt(300)]);
});

test('stacked buttons: accept presses the top one, dismiss the bottom one', async () => {
const accepting = createFakeDevice(iosThreeButtonAlert());
await (await firstDialogSeenBy(new Screen(accepting.driver))).accept();
expect(accepting.taps).toEqual([centerOfButtonAt(100)]);

const dismissing = createFakeDevice(iosThreeButtonAlert());
await (await firstDialogSeenBy(new Screen(dismissing.driver))).dismiss();
expect(dismissing.taps).toEqual([centerOfButtonAt(300)]);
});

test('an alert with a text field is a prompt, and accept types into it', async () => {
const device = createFakeDevice(iosPromptAlert());
const dialog = await firstDialogSeenBy(new Screen(device.driver));
expect(dialog.type()).toBe('prompt');
device.show(iosPromptAlert(250));
await dialog.accept('Gil');
expect(device.typed).toEqual(['Gil']);
expect(device.taps.at(-1)).toEqual(centerOfButtonAt(250, 150));
});

test('a camera permission alert is a system permission dialog', async () => {
const device = createFakeDevice(iosCameraPermissionAlert());
const dialog = await firstDialogSeenBy(new Screen(device.driver));
expect(dialog.type()).toBe('permission');
expect(dialog.isSystem()).toBe(true);
expect(dialog.title()).toBe('“Playground” would like to access the Camera.');
await dialog.accept();
expect(device.taps).toEqual([centerOfButtonAt(300, 150)]);
});

test('accepting a location permission alert allows while using the app', async () => {
const device = createFakeDevice(iosLocationPermissionAlert());
await (await firstDialogSeenBy(new Screen(device.driver))).accept();
expect(device.taps).toEqual([centerOfButtonAt(200)]);
});

test('dismissing a location permission alert does not allow', async () => {
const device = createFakeDevice(iosLocationPermissionAlert());
await (await firstDialogSeenBy(new Screen(device.driver))).dismiss();
expect(device.taps).toEqual([centerOfButtonAt(300)]);
});
});

// ─── waitForEvent ────────────────────────────────────────────────

function showLater(device: FakeDevice, tree: ViewNode[], ms: number): void {
setTimeout(() => device.show(tree), ms);
}

test.describe('screen.waitForEvent(dialog)', () => {
test('resolves with the dialog that opens after the call, polling the screen on its own', async () => {
const device = createFakeDevice(appScreenWithButton('Continue'));
const screen = new Screen(device.driver, { pollInterval: 10 });
const dialogPromise = screen.waitForEvent('dialog');
showLater(device, confirmAlert(), 50);
const dialog = await dialogPromise;
expect(dialog.title()).toBe('Confirm Alert');
});

test('leaves the dialog for the caller to answer', async () => {
const device = createFakeDevice(confirmAlert());
const screen = new Screen(device.driver, { pollInterval: 10 });
const dialog = await screen.waitForEvent('dialog');
expect(device.taps).toEqual([]);
await dialog.dismiss();
expect(device.taps).toEqual([centerOfButtonAt(100)]);
});

test('skips dialogs the predicate rejects', async () => {
const device = createFakeDevice(simpleAlert());
const screen = new Screen(device.driver, { pollInterval: 10 });
const dialogPromise = screen.waitForEvent('dialog', (dialog) => dialog.type() === 'confirm');
showLater(device, confirmAlert(), 50);
expect((await dialogPromise).title()).toBe('Confirm Alert');
});

test('rejects when no dialog opens within the timeout', async () => {
const screen = new Screen(createFakeDevice(appScreenWithButton('Continue')).driver, { pollInterval: 10 });
await expect(screen.waitForEvent('dialog', { timeout: 100 })).rejects.toThrow('Timeout 100ms exceeded while waiting for event "dialog"');
});

test('other dialog listeners still see the dialog', async () => {
const screen = new Screen(createFakeDevice(confirmAlert()).driver, { pollInterval: 10 });
let events = 0;
screen.on('dialog', () => { events++; });
await screen.waitForEvent('dialog');
expect(events).toBe(1);
});
});
Loading
Loading