Skip to content

Commit b783ab3

Browse files
committed
test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle
The test-file size ratchet rejects growth in android-lifecycle.test.ts (1,597 lines at the merge-base), and the dialog re-check work added a scenario there. The alert scenarios now live in android-alert.test.ts and the scripted dialog surfaces they share with the ANR scenarios in android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
1 parent d04da1e commit b783ab3

3 files changed

Lines changed: 357 additions & 338 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { assertCommandCall } from './assertions.ts';
4+
import { createAndroidSettingsWorld } from './android-world.ts';
5+
import {
6+
androidAppOwnedSheetXml,
7+
androidButtonlessAlertXml,
8+
androidNativeAlertXml,
9+
androidRuntimePermissionXml,
10+
androidSystemDialogXml,
11+
dismissibleDialog,
12+
} from './android-dialog-fixtures.ts';
13+
import { withProviderScenarioResource } from './harness.ts';
14+
15+
test('Provider-backed integration Android alert handles runtime permission dialog', async () => {
16+
const dialog = dismissibleDialog(androidRuntimePermissionXml);
17+
await withProviderScenarioResource(
18+
async () => await createAndroidSettingsWorld(dialog),
19+
async (world) => {
20+
const client = world.daemon.client();
21+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
22+
23+
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
24+
assert.equal(alertGet.kind, 'alertStatus');
25+
assert.deepEqual(alertGet.alert, {
26+
title: 'Allow Demo to send you notifications?',
27+
buttons: ['Don’t allow', 'Allow'],
28+
platform: 'android',
29+
source: 'permission',
30+
packageName: 'com.google.android.permissioncontroller',
31+
});
32+
33+
const alertAccept = await client.command.alert({ action: 'accept', ...world.selection });
34+
assert.equal(alertAccept.kind, 'alertHandled');
35+
assert.equal(alertAccept.button, 'Allow');
36+
assert.deepEqual(
37+
world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 274 638'),
38+
[['shell', 'input', 'tap', '274', '638']],
39+
);
40+
41+
dialog.show();
42+
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
43+
assert.equal(alertDismiss.kind, 'alertHandled');
44+
assert.equal(alertDismiss.button, 'Don’t allow');
45+
assert.deepEqual(
46+
world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 116 638'),
47+
[['shell', 'input', 'tap', '116', '638']],
48+
);
49+
},
50+
);
51+
});
52+
53+
test('Provider-backed integration Android alert handles native AlertDialog actions', async () => {
54+
const dialog = dismissibleDialog(androidNativeAlertXml);
55+
await withProviderScenarioResource(
56+
async () => await createAndroidSettingsWorld(dialog),
57+
async (world) => {
58+
const client = world.daemon.client();
59+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
60+
61+
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
62+
assert.deepEqual(alertGet.alert, {
63+
title: 'Unsaved changes',
64+
message: 'Leave without saving?',
65+
buttons: ['Cancel', 'Discard'],
66+
platform: 'android',
67+
source: 'native-dialog',
68+
packageName: 'com.example.demo',
69+
});
70+
71+
const alertAccept = await client.command.alert({ action: 'accept', ...world.selection });
72+
assert.equal(alertAccept.button, 'Discard');
73+
dialog.show();
74+
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
75+
assert.equal(alertDismiss.button, 'Cancel');
76+
assert.deepEqual(
77+
world.adbCalls.filter((call) =>
78+
['shell input tap 274 638', 'shell input tap 116 638'].includes(call.join(' ')),
79+
),
80+
[
81+
['shell', 'input', 'tap', '274', '638'],
82+
['shell', 'input', 'tap', '116', '638'],
83+
],
84+
);
85+
},
86+
);
87+
});
88+
89+
test('Provider-backed integration Android alert handles system dialogs', async () => {
90+
await withProviderScenarioResource(
91+
async () => await createAndroidSettingsWorld(dismissibleDialog(androidSystemDialogXml)),
92+
async (world) => {
93+
const client = world.daemon.client();
94+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
95+
96+
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
97+
assert.deepEqual(alertGet.alert, {
98+
title: "Demo isn't responding",
99+
message: 'Do you want to close it?',
100+
buttons: ['Close app', 'Wait'],
101+
platform: 'android',
102+
source: 'system-dialog',
103+
packageName: 'com.android.systemui',
104+
});
105+
106+
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
107+
assert.equal(alertDismiss.button, 'Close app');
108+
assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '116', '638']);
109+
},
110+
);
111+
});
112+
113+
test('Provider-backed integration Android alert dismiss falls back to Back without a dismiss button', async () => {
114+
await withProviderScenarioResource(
115+
async () => await createAndroidSettingsWorld(dismissibleDialog(androidButtonlessAlertXml)),
116+
async (world) => {
117+
const client = world.daemon.client();
118+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
119+
120+
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
121+
assert.equal(alertDismiss.kind, 'alertHandled');
122+
assert.equal(alertDismiss.button, 'Back');
123+
assertCommandCall(world.adbCalls, ['shell', 'input', 'keyevent', '4']);
124+
},
125+
);
126+
});
127+
128+
test('Provider-backed integration Android alert accept fails when the dialog stays visible', async () => {
129+
await withProviderScenarioResource(
130+
async () => await createAndroidSettingsWorld({ snapshotXml: androidNativeAlertXml }),
131+
async (world) => {
132+
const client = world.daemon.client();
133+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
134+
135+
await assert.rejects(
136+
client.command.alert({ action: 'accept', ...world.selection }),
137+
(error: unknown) =>
138+
error instanceof Error &&
139+
error.message === 'alert accept did not dismiss the visible alert',
140+
);
141+
assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '274', '638']);
142+
},
143+
);
144+
});
145+
146+
test('Provider-backed integration Android alert wait polls until a dialog appears', async () => {
147+
let snapshotCount = 0;
148+
await withProviderScenarioResource(
149+
async () =>
150+
await createAndroidSettingsWorld({
151+
snapshotXml: () => {
152+
snapshotCount += 1;
153+
return snapshotCount === 1 ? androidAppOwnedSheetXml() : androidRuntimePermissionXml();
154+
},
155+
}),
156+
async (world) => {
157+
const client = world.daemon.client();
158+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
159+
160+
const alertWait = await client.command.alert({
161+
action: 'wait',
162+
timeoutMs: 1000,
163+
...world.selection,
164+
});
165+
assert.equal(alertWait.kind, 'alertWait');
166+
// alert now returns the untyped CommandRequestResult bag (its iOS path is a
167+
// dynamic runner Record, so the public type is no longer a closed shape).
168+
const alertInfo = alertWait.alert as { source?: string } | null | undefined;
169+
assert.equal(alertInfo?.source, 'permission');
170+
assert.ok(snapshotCount >= 2);
171+
},
172+
);
173+
});
174+
175+
test('Provider-backed integration Android alert ignores app-owned sheets', async () => {
176+
await withProviderScenarioResource(
177+
async () => await createAndroidSettingsWorld({ snapshotXml: androidAppOwnedSheetXml }),
178+
async (world) => {
179+
const client = world.daemon.client();
180+
await client.apps.open({ app: 'com.example.demo', ...world.selection });
181+
182+
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
183+
assert.equal(alertGet.kind, 'alertStatus');
184+
assert.equal(alertGet.alert, null);
185+
},
186+
);
187+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Scripted Android dialog surfaces for the provider-backed scenarios: runtime permission,
3+
* native AlertDialog, system (ANR) dialog, a buttonless dialog, and the app-owned sheet that
4+
* sits underneath them.
5+
*/
6+
7+
export function androidRuntimePermissionXml(): string {
8+
const packageName = 'com.google.android.permissioncontroller';
9+
return androidXml([
10+
rootNode(packageName),
11+
textNode(
12+
1,
13+
'Allow Demo to send you notifications?',
14+
'com.android.permissioncontroller:id/permission_message',
15+
packageName,
16+
'[24,300][366,352]',
17+
),
18+
buttonNode(
19+
2,
20+
'Don’t allow',
21+
'com.android.permissioncontroller:id/permission_deny_button',
22+
'[52,612][180,664]',
23+
packageName,
24+
),
25+
buttonNode(
26+
3,
27+
'Allow',
28+
'com.android.permissioncontroller:id/permission_allow_button',
29+
'[210,612][338,664]',
30+
packageName,
31+
),
32+
' </node>',
33+
]);
34+
}
35+
36+
export function androidNativeAlertXml(): string {
37+
return androidDialogXml([
38+
textNode(2, 'Unsaved changes', 'android:id/alertTitle'),
39+
textNode(3, 'Leave without saving?', 'android:id/message'),
40+
buttonNode(4, 'Cancel', 'android:id/button2', '[52,612][180,664]'),
41+
buttonNode(5, 'Discard', 'android:id/button1', '[210,612][338,664]'),
42+
]);
43+
}
44+
45+
export function androidSystemDialogXml(): string {
46+
const packageName = 'com.android.systemui';
47+
return androidXml([
48+
rootNode(packageName),
49+
textNode(1, 'Demo isn&apos;t responding', 'android:id/alertTitle', packageName),
50+
textNode(2, 'Do you want to close it?', 'android:id/message', packageName),
51+
buttonNode(3, 'Close app', 'android:id/button2', '[52,612][180,664]', packageName),
52+
buttonNode(4, 'Wait', 'android:id/button1', '[210,612][338,664]', packageName),
53+
' </node>',
54+
]);
55+
}
56+
57+
export function androidButtonlessAlertXml(): string {
58+
return androidDialogXml([
59+
textNode(2, 'Unsaved changes', 'android:id/alertTitle'),
60+
textNode(3, 'Leave without saving?', 'android:id/message'),
61+
]);
62+
}
63+
64+
/**
65+
* A dialog the way a device shows one: in the tree until its button is tapped (or Back is
66+
* sent), then gone, with the app-owned surface underneath. `show()` brings it back for a
67+
* second action on the same fixture.
68+
*/
69+
export function dismissibleDialog(dialogXml: () => string) {
70+
let visible = true;
71+
return {
72+
show: () => {
73+
visible = true;
74+
},
75+
snapshotXml: () => (visible ? dialogXml() : androidAppOwnedSheetXml()),
76+
onAdbExec: (args: string[]) => {
77+
if (args[0] !== 'shell' || args[1] !== 'input') return;
78+
if (args[2] === 'tap' || (args[2] === 'keyevent' && args[3] === '4')) visible = false;
79+
},
80+
};
81+
}
82+
83+
export function androidAppOwnedSheetXml(): string {
84+
return androidXml([
85+
rootNode('com.example.demo', 'com.example.demo:id/root'),
86+
textNode(1, 'Choose an option', 'com.example.demo:id/title'),
87+
buttonNode(2, 'Allow', 'com.example.demo:id/allow_button', '[210,612][338,664]'),
88+
' </node>',
89+
]);
90+
}
91+
92+
function androidDialogXml(children: string[]): string {
93+
return androidXml([
94+
rootNode(),
95+
androidNode({
96+
index: 1,
97+
id: 'android:id/parentPanel',
98+
type: 'android.app.AlertDialog',
99+
bounds: '[24,240][366,680]',
100+
selfClosing: false,
101+
}),
102+
...children,
103+
' </node>',
104+
' </node>',
105+
]);
106+
}
107+
108+
function androidXml(body: string[]): string {
109+
return [
110+
'<?xml version="1.0" encoding="UTF-8"?>',
111+
'<hierarchy rotation="0">',
112+
...body,
113+
'</hierarchy>',
114+
].join('\n');
115+
}
116+
117+
function rootNode(packageName = 'com.example.demo', id = 'android:id/content'): string {
118+
return androidNode({ index: 0, id, type: 'FrameLayout', packageName, selfClosing: false });
119+
}
120+
121+
function textNode(
122+
index: number,
123+
text: string,
124+
id: string,
125+
packageName = 'com.example.demo',
126+
bounds?: string,
127+
): string {
128+
return androidNode({ index, text, id, packageName, ...(bounds ? { bounds } : {}) });
129+
}
130+
131+
function buttonNode(
132+
index: number,
133+
text: string,
134+
id: string,
135+
bounds: string,
136+
packageName = 'com.example.demo',
137+
): string {
138+
return androidNode({ index, text, id, type: 'Button', packageName, bounds, clickable: true });
139+
}
140+
141+
function androidNode(options: {
142+
index: number;
143+
id: string;
144+
text?: string;
145+
type?: string;
146+
packageName?: string;
147+
bounds?: string;
148+
clickable?: boolean;
149+
selfClosing?: boolean;
150+
}): string {
151+
const type = options.type ?? 'TextView';
152+
const className = type.includes('.') ? type : `android.widget.${type}`;
153+
const tagEnd = options.selfClosing === false ? '>' : ' />';
154+
return [
155+
` <node index="${options.index}"`,
156+
`text="${options.text ?? ''}"`,
157+
`resource-id="${options.id}"`,
158+
`class="${className}"`,
159+
`package="${options.packageName ?? 'com.example.demo'}"`,
160+
'content-desc=""',
161+
`bounds="${options.bounds ?? '[48,340][342,392]'}"`,
162+
`clickable="${options.clickable ? 'true' : 'false'}"`,
163+
'enabled="true"',
164+
options.clickable ? 'focusable="true"' : '',
165+
]
166+
.filter(Boolean)
167+
.join(' ')
168+
.concat(tagEnd);
169+
}

0 commit comments

Comments
 (0)