Skip to content

Commit d6cb962

Browse files
committed
test(android-e2e): make an alert-dismiss failure self-explaining
The Android smoke scenario asserted the post-alert canary text with a screen-wide `wait text`, whose timeout report is a top-6-label surface dump that may not include the element in question at all. Assert the canary through the specific automation-alert-result element instead, so a failure states its actual current value directly. Also record the tapped alert button's coordinates alongside its label (already recorded) in the Android alert-handled result, and capture a screenshot artifact when an e2e `wait` step times out, so a flake has more to go on than the surface dump.
1 parent 8021503 commit d6cb962

4 files changed

Lines changed: 150 additions & 6 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import type { DeviceInfo } from '@agent-device/kernel/device';
4+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
5+
6+
const runAndroidAdb = vi.fn(async (_device: DeviceInfo, _args: string[]) => ({
7+
exitCode: 0,
8+
stdout: '',
9+
stderr: '',
10+
}));
11+
vi.mock('../adb.ts', () => ({ runAndroidAdb }));
12+
13+
const { handleAndroidAlert } = await import('../alert.ts');
14+
15+
const device: DeviceInfo = {
16+
platform: 'android',
17+
id: 'emulator-5554',
18+
name: 'Pixel',
19+
kind: 'emulator',
20+
booted: true,
21+
};
22+
23+
test('dismissing a button alert records the tapped button and its coordinates', async () => {
24+
runAndroidAdb.mockClear();
25+
const result = await handleAndroidAlert(device, 'dismiss', {
26+
captureNodes: async () => [
27+
node(0, 'android.app.AlertDialog'),
28+
text(1, 'Automation confirmation', 'android:id/alertTitle'),
29+
button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }),
30+
],
31+
});
32+
33+
assert.deepEqual(result, {
34+
kind: 'alertHandled',
35+
platform: 'android',
36+
action: 'dismiss',
37+
handled: true,
38+
alert: {
39+
title: 'Automation confirmation',
40+
buttons: ['Cancel'],
41+
platform: 'android',
42+
source: 'native-dialog',
43+
packageName: 'com.example.app',
44+
},
45+
button: 'Cancel',
46+
coordinates: { x: 274, y: 638 },
47+
message: 'Alert dismissed',
48+
});
49+
assert.deepEqual(runAndroidAdb.mock.calls[0]?.[1], ['shell', 'input', 'tap', '274', '638']);
50+
});
51+
52+
test('accepting a button alert records the tapped button and its coordinates', async () => {
53+
runAndroidAdb.mockClear();
54+
const result = await handleAndroidAlert(device, 'accept', {
55+
captureNodes: async () => [
56+
node(0, 'android.app.AlertDialog'),
57+
text(1, 'Automation confirmation', 'android:id/alertTitle'),
58+
button(2, 'OK', 'android:id/button1', { x: 52, y: 612 }),
59+
],
60+
});
61+
62+
assert.equal(result.kind, 'alertHandled');
63+
assert.deepEqual('coordinates' in result ? result.coordinates : undefined, { x: 116, y: 638 });
64+
});
65+
66+
test('a fallback Back dismissal (no matching button) carries no coordinates', async () => {
67+
runAndroidAdb.mockClear();
68+
const result = await handleAndroidAlert(device, 'dismiss', {
69+
captureNodes: async () => [
70+
node(0, 'android.app.AlertDialog'),
71+
text(1, 'Automation confirmation', 'android:id/alertTitle'),
72+
],
73+
});
74+
75+
assert.equal(result.kind, 'alertHandled');
76+
assert.ok(result.kind === 'alertHandled' && !('coordinates' in result));
77+
assert.equal(result.kind === 'alertHandled' ? result.button : undefined, 'Back');
78+
assert.deepEqual(runAndroidAdb.mock.calls[0]?.[1], ['shell', 'input', 'keyevent', '4']);
79+
});
80+
81+
function node(
82+
index: number,
83+
type: string,
84+
overrides: Partial<RawSnapshotNode> = {},
85+
): RawSnapshotNode {
86+
return {
87+
index,
88+
parentIndex: index === 0 ? undefined : 0,
89+
type,
90+
bundleId: 'com.example.app',
91+
...overrides,
92+
};
93+
}
94+
95+
function text(index: number, label: string, identifier: string, parentIndex = 0): RawSnapshotNode {
96+
return node(index, 'android.widget.TextView', { label, identifier, parentIndex });
97+
}
98+
99+
function button(
100+
index: number,
101+
label: string,
102+
identifier: string,
103+
origin: { x: number; y: number },
104+
parentIndex = 0,
105+
): RawSnapshotNode {
106+
return node(index, 'android.widget.Button', {
107+
label,
108+
identifier,
109+
bundleId: 'com.example.app',
110+
parentIndex,
111+
rect: { ...origin, width: 128, height: 52 },
112+
hittable: true,
113+
});
114+
}

packages/platform-android/src/alert.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type AndroidAlertResult =
4747
handled: true;
4848
alert: AndroidAlertInfo;
4949
button: string;
50+
coordinates?: { x: number; y: number };
5051
message?: string;
5152
};
5253

@@ -102,7 +103,10 @@ async function handleAndroidAlertAction(
102103
const button = chooseAndroidAlertButton(candidate.buttons, action);
103104
if (button) {
104105
await pressAndroid(device, button.x, button.y);
105-
return buildAndroidAlertHandledResponse(action, candidate.alert, button.label);
106+
return buildAndroidAlertHandledResponse(action, candidate.alert, button.label, {
107+
x: button.x,
108+
y: button.y,
109+
});
106110
}
107111

108112
if (action === 'dismiss') {
@@ -153,6 +157,7 @@ function buildAndroidAlertHandledResponse(
153157
action: 'accept' | 'dismiss',
154158
alert: AndroidAlertInfo,
155159
button: string,
160+
coordinates?: { x: number; y: number },
156161
): AndroidAlertResult {
157162
return {
158163
kind: 'alertHandled',
@@ -161,6 +166,7 @@ function buildAndroidAlertHandledResponse(
161166
handled: true,
162167
alert,
163168
button,
169+
...(coordinates ? { coordinates } : {}),
164170
...successText(`Alert ${action}ed`),
165171
};
166172
}

test/integration/android-emulator-e2e/live-automation-scenario.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,10 @@ export async function assertAutomationSystem(context: LiveContext): Promise<void
164164
const alert = await runStep(context, 'inspect Android native alert', ['alert', 'get']);
165165
assertJsonContains(alert, 'Automation confirmation', 'alert get should expose fixture dialog');
166166
await runStep(context, 'dismiss Android native alert', ['alert', 'dismiss']);
167-
await assertWaitText(context, 'Alert result: cancelled');
167+
await assertElementText(context, 'id="automation-alert-result"', 'Alert result: cancelled');
168168
await runStep(context, 'reopen Android native alert', ['click', 'id="automation-open-alert"']);
169169
await runStep(context, 'accept Android native alert', ['alert', 'accept']);
170-
await assertWaitText(context, 'Alert result: accepted');
170+
await assertElementText(context, 'id="automation-alert-result"', 'Alert result: accepted');
171171
verifyCommand(context, C.alert, 'alert wait/get/dismiss/accept produce fixture-visible results');
172172

173173
await assertHomeAndRecentsRestoration(context);

test/integration/live-device-e2e/runtime.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export function createLiveDeviceHarness<
140140
status: result.status,
141141
step,
142142
});
143-
assertStepOutcome(context, step, fullArgs, result, failedAsExpected, stepOptions);
143+
await assertStepOutcome(context, step, fullArgs, result, failedAsExpected, stepOptions);
144144
updateSessionState(context, args[0], result.status);
145145
return result;
146146
}
@@ -158,21 +158,24 @@ export function createLiveDeviceHarness<
158158
writeStepHistory(context);
159159
}
160160

161-
function assertStepOutcome(
161+
async function assertStepOutcome(
162162
context: Context,
163163
step: string,
164164
fullArgs: string[],
165165
result: CliJsonResult,
166166
failedAsExpected: boolean,
167167
stepOptions: RunStepOptions,
168-
): void {
168+
): Promise<void> {
169169
const unexpectedFailure =
170170
result.status !== 0 && !failedAsExpected && stepOptions.allowFailure !== true;
171171
if (unexpectedFailure) {
172+
const screenshotPath =
173+
fullArgs[0] === 'wait' ? await captureWaitTimeoutScreenshot(context) : undefined;
172174
const message = [
173175
formatResultDebug(step, fullArgs, result),
174176
`scenario: ${context.currentScenario}`,
175177
`artifacts: ${context.artifactDir}`,
178+
`screenshot: ${screenshotPath ?? '(capture failed or not applicable)'}`,
176179
].join('\n');
177180
fs.writeFileSync(path.join(context.artifactDir, 'failed-step.txt'), message);
178181
assert.fail(message);
@@ -182,6 +185,27 @@ export function createLiveDeviceHarness<
182185
}
183186
}
184187

188+
/**
189+
* Best-effort evidence for a `wait` timeout: the daemon's own surface dump caps at a handful of
190+
* labels, so a screenshot is the only artifact that shows the whole screen the wait gave up on.
191+
* A failed capture must not mask the original wait failure, so this never throws.
192+
*/
193+
async function captureWaitTimeoutScreenshot(context: Context): Promise<string | undefined> {
194+
const screenshotPath = path.join(
195+
context.artifactDir,
196+
`wait-timeout-${context.stepHistory.length}.png`,
197+
);
198+
try {
199+
const capture = await (options.runCli ?? runBuiltCliJson)(
200+
options.commonFlags(context, ['screenshot', screenshotPath]),
201+
context.env,
202+
);
203+
return capture.status === 0 ? screenshotPath : undefined;
204+
} catch {
205+
return undefined;
206+
}
207+
}
208+
185209
function updateSessionState(context: Context, command: string | undefined, status: number): void {
186210
if (status !== 0) return;
187211
if (command === 'open') context.sessionOpen = true;

0 commit comments

Comments
 (0)