Skip to content
Merged
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
16 changes: 9 additions & 7 deletions examples/test-app/replays/gesture-lab-android.ad
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Coordinates target the CI emulator profile: pixel_7, 1080x2400 @ 420 dpi
# (gesture card spans y=754-1329 there). Another size/density moves the card
# Coordinates target the CI emulator profile: pixel_7, 1080x2400 @ 420 dpi, where the gesture
# targets span y=949-1525 (the two-pointer target is the left half, x=91-540). Multi-pointer
# gestures start at the target's center: a start near an edge puts the second pointer outside
# the view, which reads as "the gesture did nothing". Another size/density moves the targets
# and the canary waits below fail honestly rather than silently passing.
context platform=android kind=emulator timeout=60000

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

gesture pan 220 1040 180 0 500
gesture pan 300 1237 180 0 500
wait "two-pointer pan activations 0" 5000

gesture pan 220 1040 180 0 500 --pointer-count 2
gesture pan 300 1237 180 0 500 --pointer-count 2
wait "two-pointer pan activations 1" 5000

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

gesture pinch 1.5 540 1040
gesture pinch 1.5 540 1237
wait "pan changed no, pinch changed yes, rotate changed no" 5000

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

gesture rotate 35 540 1040
gesture rotate 35 540 1237
wait "pan changed no, pinch changed no, rotate changed yes" 5000

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

gesture transform 540 1040 60 0 1.75 30 600
gesture transform 540 1237 60 0 1.75 30 600
wait "pan changed yes, pinch changed yes, rotate changed yes" 5000

close
4 changes: 4 additions & 0 deletions examples/test-app/src/screens/CatalogScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function CatalogScreen(props: CatalogScreenProps) {
onScroll={updateScrollState}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
// Pins the scroll-state line (child index 1) so every state it reports stays on screen.
// Android accessibility snapshots carry on-screen nodes only, so a canary that scrolls
// away can only ever be read as "top" — the state it holds before anything scrolls.
stickyHeaderIndices={[1]}
>
<ScreenTitle
badge={`${props.products.length} results`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';

const C = PUBLIC_COMMANDS;
const ANDROID_TEST_IME_PACKAGE = 'com.callstack.agentdevice.imehelper';
export const ANDROID_TEST_IME_PACKAGE = 'com.callstack.agentdevice.imehelper';
const ANDROID_TEST_IME_SERVICE = `${ANDROID_TEST_IME_PACKAGE}/.TestInputMethodService`;
const KEYBOARD_VISIBILITY_TIMEOUT_MS = 10_000;
const KEYBOARD_VISIBILITY_POLL_MS = 250;
Expand Down
55 changes: 39 additions & 16 deletions test/integration/android-emulator-e2e/live-lifecycle-scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@ import assert from 'node:assert/strict';

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

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

export async function assertLifecycleAndSystem(context: LiveContext): Promise<void> {
await runStep(context, 'open Android fixture lifecycle route', [
'open',
context.appId,
'--relaunch',
AUTOMATION_DEEP_LINK,
]);
await assertWaitText(context, 'Automation lab');
await openAutomationLab(context, 'open Android fixture lifecycle route');

await assertPermissionRecovery(context);
await assertPushBroadcast(context);
await assertImeRecovery(context);
}

// Leaves the fixture on Automation lab with its permission controls on screen: Android
// snapshots only carry on-screen nodes, and a relaunch always lands above the controls.
async function openAutomationLab(context: LiveContext, step: string): Promise<void> {
await runStep(context, step, ['open', context.appId, '--relaunch', AUTOMATION_DEEP_LINK]);
await assertWaitText(context, 'Automation lab');
await runStep(context, `${step} (reveal automation controls)`, ['scroll', 'bottom']);
}

async function assertPermissionRecovery(context: LiveContext): Promise<void> {
await resetAndRequestMicrophonePermission(context, 'accept');
await assertElementText(context, 'id="automation-microphone-permission"', 'granted');
Expand All @@ -39,9 +43,7 @@ async function assertPermissionRecovery(context: LiveContext): Promise<void> {
'deny',
'microphone',
]);
await runStep(context, 'background fixture before revoked permission readback', ['home']);
await runStep(context, 'restore fixture after microphone revocation', ['open', context.appId]);
await assertWaitText(context, 'Automation lab');
await openAutomationLab(context, 'restore fixture after microphone revocation');
await assertElementText(context, 'id="automation-microphone-permission"', 'denied');
verifyCommand(
context,
Expand All @@ -51,10 +53,13 @@ async function assertPermissionRecovery(context: LiveContext): Promise<void> {
verifyBehavior(
context,
'runtime-permission-recovery',
'fixture observed native prompt accept and deny, then app-active readback after pm revoke',
'fixture observed native prompt accept and deny, then relaunched readback after the pm revoke killed the app',
);
}

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

async function assertImeRecovery(context: LiveContext): Promise<void> {
// Automation lab is a root route without the tab bar, so the tabs have to be restored
// before the Form tab can be clicked. This section observes the system IME, and only
// closing the session puts the test IME back, so the reopen follows a close.
await runStep(context, 'release Android test IME before IME recovery', ['close']);
await runStep(context, 'return to Android fixture tabs before IME recovery', [
'open',
context.appId,
'--relaunch',
'--no-test-ime',
TABS_DEEP_LINK,
]);
await assertWaitText(context, 'Agent Device Tester');
await runStep(context, 'open Android fixture form for IME recovery', ['click', 'label="Form"']);
await assertWaitText(context, 'Checkout form');
await runStep(context, 'fill Android IME recovery field', [
Expand All @@ -121,8 +135,17 @@ async function assertImeRecovery(context: LiveContext): Promise<void> {
'status',
]);
assert.equal(status.json?.data?.visible, true, JSON.stringify(status.json));
// Names what `visible: true` only implies: the injecting test IME draws nothing, so reading
// it here would mean the reopen above failed to hand input back to the system keyboard.
assert.notEqual(
status.json?.data?.inputMethodPackage,
ANDROID_TEST_IME_PACKAGE,
JSON.stringify(status.json),
);
await runStep(context, 'dismiss Android IME after fill recovery', ['keyboard', 'dismiss']);
await runStep(context, 'scroll to Android IME diagnostic', ['scroll', 'bottom']);
// The diagnostic sits between the IME field and the delivery choices, so scrolling to the
// bottom of the form scrolls past it.
await runStep(context, 'scroll to Android IME diagnostic', ['scroll', 'down', '0.3']);
await assertWaitText(context, 'Android fill input was captured by the active keyboard');
await assertElementText(context, 'id="field-ime-capture-target"', 'agent-device');
verifyBehavior(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ async function assertLogs(context: LiveContext): Promise<void> {

async function assertTraceAndRecording(context: LiveContext): Promise<void> {
const tracePath = path.join(context.artifactDir, 'fixture.adtrace');
await runStep(context, 'reveal Android quick actions before trace', ['scroll', 'down', '0.7']);
// 0.7 of a viewport scrolls the quick actions off the top on the pixel_7 profile the
// nightly lane pins; half a viewport puts the whole card on screen.
await runStep(context, 'reveal Android quick actions before trace', ['scroll', 'down', '0.5']);
await runStep(context, 'start Android interaction trace', ['trace', 'start', tracePath]);
await runStep(context, 'trace Android visible mutation', ['press', 'id="home-open-catalog"']);
await runStep(context, 'stop Android interaction trace', ['trace', 'stop', tracePath]);
Expand Down Expand Up @@ -104,6 +106,10 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
await runStep(context, 'return to Android fixture home before batch', ['click', 'label="Home"']);
await runStep(context, 'restore Android fixture home title before batch', ['scroll', 'top']);
await assertWaitText(context, 'Agent Device Tester');
// Android snapshots only carry on-screen nodes, so both batch targets are revealed first.
// 0.3 of a viewport is the band where the gesture lab card and the release notice below it
// are on screen together on the pixel_7 profile the lane pins; by 0.4 the card is gone.
await runStep(context, 'reveal Android batch targets', ['scroll', 'down', '0.3']);
const batch = await runStep(context, 'run Android nested semantic read batch', [
'batch',
'--steps',
Expand All @@ -114,7 +120,9 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
},
{
command: 'is',
input: { predicate: 'visible', selector: 'id="home-title"' },
// A sibling card, not the notice that owns `dismiss-notice`: resolving a child already
// proves its parent is present, so a parent target could not fail on its own.
input: { predicate: 'visible', selector: 'id="gesture-lab-card"' },
},
]),
]);
Expand All @@ -135,7 +143,9 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
const result = await runStep(
context,
cursor === undefined ? 'read Android event timeline' : `read Android events from ${cursor}`,
cursor === undefined ? ['events', '4'] : ['events', '4', cursor],
// Each page read appends its own request events, so a page smaller than that tail
// never catches up with a full-tier session's timeline.
cursor === undefined ? ['events', '50'] : ['events', '50', cursor],
);
return (result.json?.data ?? {}) as EventTimelinePage;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ env APP_TARGET="com.callstack.agentdevicelab"

open "${APP_TARGET}" --relaunch
wait "Agent Device Tester" 30000
click "label=\"Catalog, 0 new notifications\""
click "label=\"Catalog\""
wait "Catalog scroll: top" 5000
swipe 220 700 220 380
wait "Catalog scroll: down" 5000
Expand Down
Loading