Skip to content

Commit e3f3a24

Browse files
authored
test(android): restore full-tier lifecycle and observability scenarios (#1781 A1) (#1793)
* test(android): restore full-tier lifecycle and observability scenarios (#1781 A1) The nightly Android job has failed on `click id="automation-request-microphone"` since the full tier landed: `settings permission reset microphone` runs `pm revoke`, and revoking a *granted* runtime permission kills the app process, so the round after an accept clicked into an empty launcher surface. Reproduced on the pinned CI image (android-36 google_apis_playstore, Pixel 7): pm revoke leaves pid 4259 alive when the permission is denied and kills it when it is granted, with NexusLauncher resumed afterwards. Fixing that exposed the rest of a scenario that had never executed end to end: the post-revoke readback cold-started on the tabs home instead of Automation lab, the relaunched Automation lab needed its controls revealed, the Form tab does not exist on the Automation root route, that section needs the system IME back, and the IME diagnostic sits above the bottom of the form. The observability scenario then failed the same way (reveal distances tuned for a taller device) plus an event-timeline walk whose page was smaller than the events each page read appends. Validated live against a local Pixel_7_CI emulator (API 36, same profile as the lane): the full tier now runs bootstrap -> inventory -> automation-system -> form-input -> keyboard-ime -> capture-close -> lifecycle-system -> observability-artifacts and stops only in full:fixture-replays. * test(android): repair the drifted fixture replays and pin the catalog canary (#1781 A1) Review follow-up. The nested batch regression now checks a sibling card instead of the notice that owns `dismiss-notice`: resolving a child already proves its parent is present, so the old target could not fail on its own. Confirmed on a Pixel 7 / API 36 emulator that `gesture-lab-card` and `dismiss-notice` are on screen together at the scenario's existing 0.3 reveal (both present at 0.2-0.4; the card is gone by 0.5). Getting a full-tier run to complete then required repairing what the lane had never executed: - `01-navigation-scroll.ad` clicked `label="Catalog, 0 new notifications"`. #1543 made the cart badge conditional, so the live label is `Catalog` — what the iOS twin already used. - The catalog scroll canary lives inside the scrolling content, and Android accessibility snapshots carry on-screen nodes only, so every state except the initial `top` was unobservable: `wait "Catalog scroll: down|bottom|up"` could never pass, whatever the swipe coordinates were. `stickyHeaderIndices` pins that one line, which makes all four states readable at any offset on both platforms rather than tuning the .ad around a canary that scrolls away. - `gesture-lab-android.ad` started its multi-pointer gestures at y=1040, inside the target when the file was last repaired but 90px from its top edge after #1567 moved the card (targets now span y=949-1525). The second pointer landed outside the view, which reads as "the gesture did nothing". Multi-pointer gestures now start at the target centre, and the header comment records the geometry they depend on. Evidence: the lane's own command (`AGENT_DEVICE_ANDROID_E2E_TIER=full` over smoke-android-emulator.test.ts) passes end to end on a Pixel 7 / API 36 AVD with a CI-equivalent fixture APK (cached native + head JS through the same repack the workflow runs): 9/9 scenarios, 153s.
1 parent f45228a commit e3f3a24

6 files changed

Lines changed: 67 additions & 28 deletions

File tree

examples/test-app/replays/gesture-lab-android.ad

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
# Coordinates target the CI emulator profile: pixel_7, 1080x2400 @ 420 dpi
2-
# (gesture card spans y=754-1329 there). Another size/density moves the card
1+
# Coordinates target the CI emulator profile: pixel_7, 1080x2400 @ 420 dpi, where the gesture
2+
# targets span y=949-1525 (the two-pointer target is the left half, x=91-540). Multi-pointer
3+
# gestures start at the target's center: a start near an edge puts the second pointer outside
4+
# the view, which reads as "the gesture did nothing". Another size/density moves the targets
35
# and the canary waits below fail honestly rather than silently passing.
46
context platform=android kind=emulator timeout=60000
57

@@ -12,10 +14,10 @@ wait "Gesture lab" 30000
1214
wait "gesture canary ready" 5000
1315
wait "two-pointer pan activations 0" 5000
1416

15-
gesture pan 220 1040 180 0 500
17+
gesture pan 300 1237 180 0 500
1618
wait "two-pointer pan activations 0" 5000
1719

18-
gesture pan 220 1040 180 0 500 --pointer-count 2
20+
gesture pan 300 1237 180 0 500 --pointer-count 2
1921
wait "two-pointer pan activations 1" 5000
2022

2123
gesture fling right 700 1040 300
@@ -29,23 +31,23 @@ react-native dismiss-overlay
2931
wait "gesture canary ready" 5000
3032
wait "pan changed no, pinch changed no, rotate changed no" 30000
3133

32-
gesture pinch 1.5 540 1040
34+
gesture pinch 1.5 540 1237
3335
wait "pan changed no, pinch changed yes, rotate changed no" 5000
3436

3537
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
3638
react-native dismiss-overlay
3739
wait "gesture canary ready" 5000
3840
wait "pan changed no, pinch changed no, rotate changed no" 30000
3941

40-
gesture rotate 35 540 1040
42+
gesture rotate 35 540 1237
4143
wait "pan changed no, pinch changed no, rotate changed yes" 5000
4244

4345
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
4446
react-native dismiss-overlay
4547
wait "gesture canary ready" 5000
4648
wait "pan changed no, pinch changed no, rotate changed no" 30000
4749

48-
gesture transform 540 1040 60 0 1.75 30 600
50+
gesture transform 540 1237 60 0 1.75 30 600
4951
wait "pan changed yes, pinch changed yes, rotate changed yes" 5000
5052

5153
close

examples/test-app/src/screens/CatalogScreen.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export function CatalogScreen(props: CatalogScreenProps) {
6262
onScroll={updateScrollState}
6363
scrollEventThrottle={16}
6464
showsVerticalScrollIndicator={false}
65+
// Pins the scroll-state line (child index 1) so every state it reports stays on screen.
66+
// Android accessibility snapshots carry on-screen nodes only, so a canary that scrolls
67+
// away can only ever be read as "top" — the state it holds before anything scrolls.
68+
stickyHeaderIndices={[1]}
6569
>
6670
<ScreenTitle
6771
badge={`${props.products.length} results`}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';
1515

1616
const C = PUBLIC_COMMANDS;
17-
const ANDROID_TEST_IME_PACKAGE = 'com.callstack.agentdevice.imehelper';
17+
export const ANDROID_TEST_IME_PACKAGE = 'com.callstack.agentdevice.imehelper';
1818
const ANDROID_TEST_IME_SERVICE = `${ANDROID_TEST_IME_PACKAGE}/.TestInputMethodService`;
1919
const KEYBOARD_VISIBILITY_TIMEOUT_MS = 10_000;
2020
const KEYBOARD_VISIBILITY_POLL_MS = 250;

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

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,31 @@ import assert from 'node:assert/strict';
22

33
import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
44
import { assertElementText, assertWaitText } from './live-assertions.ts';
5+
import { ANDROID_TEST_IME_PACKAGE } from './live-form-scenario.ts';
56
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';
67

78
const C = PUBLIC_COMMANDS;
89
const AUTOMATION_DEEP_LINK =
910
'agent-device-test-app:///automation?event=full.lifecycle&payload=%7B%22source%22%3A%22android-nightly%22%7D';
11+
const TABS_DEEP_LINK = 'agent-device-test-app:///';
1012
export const ANDROID_PERMISSION_PROMPT_COMMAND = ['alert', 'wait', '10000'] as const;
1113

1214
export async function assertLifecycleAndSystem(context: LiveContext): Promise<void> {
13-
await runStep(context, 'open Android fixture lifecycle route', [
14-
'open',
15-
context.appId,
16-
'--relaunch',
17-
AUTOMATION_DEEP_LINK,
18-
]);
19-
await assertWaitText(context, 'Automation lab');
15+
await openAutomationLab(context, 'open Android fixture lifecycle route');
2016

2117
await assertPermissionRecovery(context);
2218
await assertPushBroadcast(context);
2319
await assertImeRecovery(context);
2420
}
2521

22+
// Leaves the fixture on Automation lab with its permission controls on screen: Android
23+
// snapshots only carry on-screen nodes, and a relaunch always lands above the controls.
24+
async function openAutomationLab(context: LiveContext, step: string): Promise<void> {
25+
await runStep(context, step, ['open', context.appId, '--relaunch', AUTOMATION_DEEP_LINK]);
26+
await assertWaitText(context, 'Automation lab');
27+
await runStep(context, `${step} (reveal automation controls)`, ['scroll', 'bottom']);
28+
}
29+
2630
async function assertPermissionRecovery(context: LiveContext): Promise<void> {
2731
await resetAndRequestMicrophonePermission(context, 'accept');
2832
await assertElementText(context, 'id="automation-microphone-permission"', 'granted');
@@ -39,9 +43,7 @@ async function assertPermissionRecovery(context: LiveContext): Promise<void> {
3943
'deny',
4044
'microphone',
4145
]);
42-
await runStep(context, 'background fixture before revoked permission readback', ['home']);
43-
await runStep(context, 'restore fixture after microphone revocation', ['open', context.appId]);
44-
await assertWaitText(context, 'Automation lab');
46+
await openAutomationLab(context, 'restore fixture after microphone revocation');
4547
await assertElementText(context, 'id="automation-microphone-permission"', 'denied');
4648
verifyCommand(
4749
context,
@@ -51,10 +53,13 @@ async function assertPermissionRecovery(context: LiveContext): Promise<void> {
5153
verifyBehavior(
5254
context,
5355
'runtime-permission-recovery',
54-
'fixture observed native prompt accept and deny, then app-active readback after pm revoke',
56+
'fixture observed native prompt accept and deny, then relaunched readback after the pm revoke killed the app',
5557
);
5658
}
5759

60+
// Android kills the app process whenever a *granted* runtime permission is revoked, and
61+
// `settings permission reset` revokes. Every reset therefore relaunches the fixture so the
62+
// following steps always run against a live Automation lab.
5863
async function resetAndRequestMicrophonePermission(
5964
context: LiveContext,
6065
action: 'accept' | 'deny' | 'accept after denial',
@@ -65,10 +70,7 @@ async function resetAndRequestMicrophonePermission(
6570
'reset',
6671
'microphone',
6772
]);
68-
await runStep(context, `scroll to microphone permission control before ${action}`, [
69-
'scroll',
70-
'bottom',
71-
]);
73+
await openAutomationLab(context, `restore fixture after reset before ${action}`);
7274
await runStep(context, `request Android microphone permission for ${action}`, [
7375
'click',
7476
'id="automation-request-microphone"',
@@ -109,6 +111,18 @@ async function assertPushBroadcast(context: LiveContext): Promise<void> {
109111
}
110112

111113
async function assertImeRecovery(context: LiveContext): Promise<void> {
114+
// Automation lab is a root route without the tab bar, so the tabs have to be restored
115+
// before the Form tab can be clicked. This section observes the system IME, and only
116+
// closing the session puts the test IME back, so the reopen follows a close.
117+
await runStep(context, 'release Android test IME before IME recovery', ['close']);
118+
await runStep(context, 'return to Android fixture tabs before IME recovery', [
119+
'open',
120+
context.appId,
121+
'--relaunch',
122+
'--no-test-ime',
123+
TABS_DEEP_LINK,
124+
]);
125+
await assertWaitText(context, 'Agent Device Tester');
112126
await runStep(context, 'open Android fixture form for IME recovery', ['click', 'label="Form"']);
113127
await assertWaitText(context, 'Checkout form');
114128
await runStep(context, 'fill Android IME recovery field', [
@@ -121,8 +135,17 @@ async function assertImeRecovery(context: LiveContext): Promise<void> {
121135
'status',
122136
]);
123137
assert.equal(status.json?.data?.visible, true, JSON.stringify(status.json));
138+
// Names what `visible: true` only implies: the injecting test IME draws nothing, so reading
139+
// it here would mean the reopen above failed to hand input back to the system keyboard.
140+
assert.notEqual(
141+
status.json?.data?.inputMethodPackage,
142+
ANDROID_TEST_IME_PACKAGE,
143+
JSON.stringify(status.json),
144+
);
124145
await runStep(context, 'dismiss Android IME after fill recovery', ['keyboard', 'dismiss']);
125-
await runStep(context, 'scroll to Android IME diagnostic', ['scroll', 'bottom']);
146+
// The diagnostic sits between the IME field and the delivery choices, so scrolling to the
147+
// bottom of the form scrolls past it.
148+
await runStep(context, 'scroll to Android IME diagnostic', ['scroll', 'down', '0.3']);
126149
await assertWaitText(context, 'Android fill input was captured by the active keyboard');
127150
await assertElementText(context, 'id="field-ime-capture-target"', 'agent-device');
128151
verifyBehavior(

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ async function assertLogs(context: LiveContext): Promise<void> {
6565

6666
async function assertTraceAndRecording(context: LiveContext): Promise<void> {
6767
const tracePath = path.join(context.artifactDir, 'fixture.adtrace');
68-
await runStep(context, 'reveal Android quick actions before trace', ['scroll', 'down', '0.7']);
68+
// 0.7 of a viewport scrolls the quick actions off the top on the pixel_7 profile the
69+
// nightly lane pins; half a viewport puts the whole card on screen.
70+
await runStep(context, 'reveal Android quick actions before trace', ['scroll', 'down', '0.5']);
6971
await runStep(context, 'start Android interaction trace', ['trace', 'start', tracePath]);
7072
await runStep(context, 'trace Android visible mutation', ['press', 'id="home-open-catalog"']);
7173
await runStep(context, 'stop Android interaction trace', ['trace', 'stop', tracePath]);
@@ -104,6 +106,10 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
104106
await runStep(context, 'return to Android fixture home before batch', ['click', 'label="Home"']);
105107
await runStep(context, 'restore Android fixture home title before batch', ['scroll', 'top']);
106108
await assertWaitText(context, 'Agent Device Tester');
109+
// Android snapshots only carry on-screen nodes, so both batch targets are revealed first.
110+
// 0.3 of a viewport is the band where the gesture lab card and the release notice below it
111+
// are on screen together on the pixel_7 profile the lane pins; by 0.4 the card is gone.
112+
await runStep(context, 'reveal Android batch targets', ['scroll', 'down', '0.3']);
107113
const batch = await runStep(context, 'run Android nested semantic read batch', [
108114
'batch',
109115
'--steps',
@@ -114,7 +120,9 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
114120
},
115121
{
116122
command: 'is',
117-
input: { predicate: 'visible', selector: 'id="home-title"' },
123+
// A sibling card, not the notice that owns `dismiss-notice`: resolving a child already
124+
// proves its parent is present, so a parent target could not fail on its own.
125+
input: { predicate: 'visible', selector: 'id="gesture-lab-card"' },
118126
},
119127
]),
120128
]);
@@ -135,7 +143,9 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
135143
const result = await runStep(
136144
context,
137145
cursor === undefined ? 'read Android event timeline' : `read Android events from ${cursor}`,
138-
cursor === undefined ? ['events', '4'] : ['events', '4', cursor],
146+
// Each page read appends its own request events, so a page smaller than that tail
147+
// never catches up with a full-tier session's timeline.
148+
cursor === undefined ? ['events', '50'] : ['events', '50', cursor],
139149
);
140150
return (result.json?.data ?? {}) as EventTimelinePage;
141151
});

test/integration/replays/android/fixture/01-navigation-scroll.ad

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ env APP_TARGET="com.callstack.agentdevicelab"
55

66
open "${APP_TARGET}" --relaunch
77
wait "Agent Device Tester" 30000
8-
click "label=\"Catalog, 0 new notifications\""
8+
click "label=\"Catalog\""
99
wait "Catalog scroll: top" 5000
1010
swipe 220 700 220 380
1111
wait "Catalog scroll: down" 5000

0 commit comments

Comments
 (0)