Skip to content

Commit be0e48b

Browse files
committed
test(android): read ANR recovery through the Android capture envelope
PR #2003's Coverage lane failed two cases in `android-dialog-readiness.test.ts` with "Android app ANR blocked tap: ... Automatic recovery failed." CI runs the branch merged into `main`, and #1981 landed on `main` after this branch's last rebase: Android publication now derives covered state from acquisition evidence carried in an opaque capture envelope, so `readAndroidSnapshotNodes` goes through `androidSnapshotPublicationInput`. This branch's new readiness test mocked `snapshotAndroid` with a bare `{ nodes }`, publication threw on the missing evidence, and the ANR recovery's own catch reported that as "recovery failed" - a fixture defect that reads exactly like broken recovery. Rebased onto `origin/main` and built the fake capture with the production constructor (`makeAndroidSnapshotCapture`), so the fixture cannot drift from what production publishes. No recovery guarantee is relaxed; two are pinned harder: - the recovered case now asserts the tap that dismissed the dialog, not the status alone, so a status reported without the device work behind it fails; - a new case pins #1832 on the readiness path - a "Close app" covered by a foreground surface is never tapped, and recovery reports failure so the command fails closed. Verified red-first by mutation: dropping the occlusion filter in `findCloseAppButton` makes the new case fail (it taps the covered button and reports recovery). Gates: `pnpm vitest run src/daemon src/platforms/android` (383 files / 2780 tests), full `unit-core` (1035 files / 7888 tests), `pnpm format`, `tsc --noEmit`, `pnpm check:affected --run`.
1 parent 380d9ba commit be0e48b

2 files changed

Lines changed: 89 additions & 7 deletions

File tree

‎RESULT.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ What changed:
198198
`main` either) but emits `android_blocking_dialog_unobserved`, and nothing about that dump can be
199199
carried forward as evidence.
200200

201+
- **CI — rebased again onto `main`'s #1981.** The PR's Coverage lane tests the branch merged into
202+
`main`, and #1981 landed there after the first rebase: Android publication now derives covered
203+
state from acquisition evidence that travels in an opaque capture envelope, and
204+
`readAndroidSnapshotNodes` goes through `androidSnapshotPublicationInput`. This branch's new
205+
`android-dialog-readiness.test.ts` mocked `snapshotAndroid` with a bare `{ nodes }`, so
206+
publication threw, the ANR recovery's own catch reported "Automatic recovery failed", and two
207+
cases failed on a fixture defect that looked like broken recovery. The fixture now builds its
208+
capture with the production constructor (`makeAndroidSnapshotCapture`), the recovered case pins
209+
the tap that dismissed the dialog rather than the status alone, and a new case pins #1832 on the
210+
readiness path: a "Close app" covered by a foreground surface is never tapped and recovery
211+
reports failure.
212+
201213
Left open, not in this pass: **F5** (the ANR poll is attempt-bounded, so cheaper probes shortened
202214
its wall-clock window), **F6** (the promoted `type` path has no delivery or helper-version check),
203215
**F7** (`#1832` citations corrected to `#592` where this pass rewrote them; other `#1832`

‎src/daemon/__tests__/android-dialog-readiness.test.ts‎

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
import { expect, test, vi } from 'vitest';
1+
import { beforeEach, expect, test, vi } from 'vitest';
2+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
23

4+
/**
5+
* Recovery reads the daemon presentation, and Android publication derives covered state from the
6+
* acquisition evidence the capture envelope carries (#1981). The fake capture is therefore built by
7+
* the production constructor: a bare `{ nodes }` throws inside publication, and recovery would then
8+
* report "recovery failed" for a fixture reason rather than a device one.
9+
*/
310
vi.mock('../../platforms/android/snapshot.ts', async (importOriginal) => {
411
const actual = await importOriginal<typeof import('../../platforms/android/snapshot.ts')>();
12+
const { makeAndroidSnapshotCapture } =
13+
await import('../../__tests__/test-utils/android-snapshot-capture.ts');
514
return {
615
...actual,
7-
snapshotAndroid: vi.fn(async () => ({ nodes: anrDialogNodes() })),
16+
snapshotAndroid: vi.fn(async () => makeAndroidSnapshotCapture(screenNodes)),
817
};
918
});
1019

@@ -26,8 +35,17 @@ const NORMAL_FOCUS_DUMP = ` mCurrentFocus=Window{a1b2c3 u0 ${APP}/${APP}.MainAc
2635
const ANR_FOCUS_DUMP = ` mCurrentFocus=Window{ffeedd u0 Application Not Responding: ${APP}}`;
2736
const SYSTEM_DIALOG_DUMP =
2837
' mCurrentFocus=Window{ffeedd u0 Application Not Responding: com.android.systemui}';
38+
const CLOSE_APP_RECT = { x: 100, y: 600, width: 220, height: 80 };
39+
const CLOSE_APP_TAP = ['shell', 'input', 'tap', '210', '640'];
40+
41+
/** What the mocked capture publishes: the screen the case under test is recovering from. */
42+
let screenNodes: RawSnapshotNode[];
43+
44+
beforeEach(() => {
45+
screenNodes = anrDialogNodes();
46+
});
2947

30-
function anrDialogNodes() {
48+
function anrDialogNodes(): RawSnapshotNode[] {
3149
return [
3250
{
3351
index: 0,
@@ -39,11 +57,32 @@ function anrDialogNodes() {
3957
index: 1,
4058
type: 'android.widget.Button',
4159
label: 'Close app',
42-
rect: { x: 100, y: 600, width: 220, height: 80 },
60+
rect: CLOSE_APP_RECT,
4361
},
4462
];
4563
}
4664

65+
/**
66+
* The same dialog with a foreground sheet drawn over it: the "Close app" is in the tree but nothing
67+
* a user could touch. #1832 — recovery acts on what is reachable, never on raw text.
68+
*/
69+
function coveredCloseAppNodes(): RawSnapshotNode[] {
70+
return [
71+
...anrDialogNodes(),
72+
{
73+
index: 2,
74+
type: 'com.google.android.material.bottomsheet.BottomSheetDialog',
75+
identifier: 'com.example:id/bottom_sheet',
76+
hittable: true,
77+
rect: { x: 0, y: 0, width: 1080, height: 2400 },
78+
},
79+
];
80+
}
81+
82+
function tapCalls(calls: string[][]): string[][] {
83+
return calls.filter((call) => call[1] === 'input' && call[2] === 'tap');
84+
}
85+
4786
/**
4887
* A device whose focused window is `dialog` until recovery taps "Close app", and the app after
4988
* that — the tap is what dismisses the dialog on a real device too.
@@ -69,28 +108,59 @@ test('an ANR that surfaces between two commands is recovered by the next pre-dis
69108
const session = makeAndroidSession('readiness-anr-between', { appBundleId: APP });
70109
let anr = false;
71110

72-
const readiness = await withFakeAdb(
111+
const { readiness, calls } = await withFakeAdb(
73112
deviceShowing(() => (anr ? ANR_FOCUS_DUMP : null)),
74-
async ({ device }) => {
113+
async ({ device, calls }) => {
75114
session.device = device;
76115
await ensureAndroidBlockingSystemDialogReady({
77116
session,
78117
command: 'tap',
79118
phase: 'after-command',
80119
});
81120
anr = true;
82-
return await ensureAndroidBlockingSystemDialogReady({
121+
const readiness = await ensureAndroidBlockingSystemDialogReady({
83122
session,
84123
command: 'tap',
85124
phase: 'before-command',
86125
});
126+
return { readiness, calls };
87127
},
88128
);
89129

90130
expect(readiness).toEqual({
91131
status: 'recovered',
92132
warning: `Recovered Android app ANR before tap: closed and relaunched ${APP}.`,
93133
});
134+
// Recovering means the dialog was actually dismissed on the device, so the reported status is
135+
// pinned to the tap that did it rather than to the status value alone.
136+
expect(tapCalls(calls)).toEqual([CLOSE_APP_TAP]);
137+
});
138+
139+
test('an ANR whose Close app is covered is not tapped, and recovery reports failure', async () => {
140+
// #1832: recovery reads the presentation's occlusion result, so a "Close app" that a foreground
141+
// surface covers is unreachable. Tapping it anyway would land the tap on whatever is on top.
142+
const session = makeAndroidSession('readiness-anr-covered', { appBundleId: APP });
143+
screenNodes = coveredCloseAppNodes();
144+
145+
const { calls } = await withFakeAdb(
146+
deviceShowing(() => ANR_FOCUS_DUMP),
147+
async ({ device, calls }) => {
148+
session.device = device;
149+
await expect(
150+
ensureAndroidBlockingSystemDialogReady({
151+
session,
152+
command: 'tap',
153+
phase: 'before-command',
154+
}),
155+
).rejects.toMatchObject({
156+
code: 'COMMAND_FAILED',
157+
message: expect.stringContaining('Android app ANR blocked tap'),
158+
});
159+
return { calls };
160+
},
161+
);
162+
163+
expect(tapCalls(calls)).toEqual([]);
94164
});
95165

96166
test('a system dialog that surfaces between two commands blocks the next command', async () => {

0 commit comments

Comments
 (0)