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
33 changes: 33 additions & 0 deletions src/compat/maestro/__tests__/runtime-assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,39 @@ test('invokeMaestroAssertVisible does not use Android raw fallback for generated
);
});

test('invokeMaestroAssertVisible bounds Android verification retries after native wait succeeds', async () => {
vi.useFakeTimers();

const calls: Array<[string, string[] | undefined]> = [];
const responsePromise = invokeMaestroAssertVisible({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android' },
},
positionals: ['label="Input" || text="Input" || id="Input"', '60000'],
invoke: async (req): Promise<DaemonResponse> => {
calls.push([req.command, req.positionals]);
if (req.command === 'snapshot') {
return { ok: true, data: snapshot([node('Loading')]) };
}
if (req.command === 'wait') return { ok: true, data: { matches: 1 } };
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

await vi.advanceTimersByTimeAsync(6500);
const response = await responsePromise;

assert.equal(response.ok, false);
assert.deepEqual(calls.slice(0, 3), [
['wait', ['Input', '60000']],
['snapshot', []],
['snapshot', []],
]);
assert.ok(calls.filter(([command]) => command === 'snapshot').length < 40);
});

test('invokeMaestroAssertVisible writes terminal snapshot artifacts for failed attempts', async () => {
const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-assert-artifacts-'));
try {
Expand Down
17 changes: 15 additions & 2 deletions src/compat/maestro/runtime-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async function invokeNativeMaestroVisibleWaitWithSnapshotFallback(
nativeStartedAt,
);
if (failedSample.kind === 'return') return failedSample.response;
return await invokeSnapshotMaestroAssertVisible(params, args);
return await invokeSnapshotMaestroAssertVisible(params, visibleAssertionRetryArgs(args));
}
}
rememberMaestroVisibleContext(params.scope, args.selector);
Expand Down Expand Up @@ -277,7 +277,7 @@ async function confirmVisibleAfterAndroidRecovery(
): Promise<DaemonResponse> {
const retryArgs = {
...args,
timeoutMs: Math.min(args.timeoutMs, MAESTRO_ASSERTION_POLICY.assertVisibleRetryTimeoutMs),
timeoutMs: visibleAssertionRetryTimeoutMs(args.timeoutMs),
};
const nativeWaitQuery = readNativeVisibleWaitQuery(params.baseReq, retryArgs.selector);
if (!nativeWaitQuery) return await invokeSnapshotMaestroAssertVisible(params, retryArgs);
Expand Down Expand Up @@ -431,6 +431,19 @@ function readVisibleAssertionDeadlineAction(params: {
: 'finish';
}

function visibleAssertionRetryArgs(
args: MaestroVisibilityAssertionArgs,
): MaestroVisibilityAssertionArgs {
return {
...args,
timeoutMs: visibleAssertionRetryTimeoutMs(args.timeoutMs),
};
}

function visibleAssertionRetryTimeoutMs(timeoutMs: number): number {
return Math.min(timeoutMs, MAESTRO_ASSERTION_POLICY.assertVisibleRetryTimeoutMs);
}

function isReactNativeOverlayBlockingAssertion(response: DaemonResponse): boolean {
return (
!response.ok &&
Expand Down
19 changes: 19 additions & 0 deletions src/compat/maestro/runtime-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { DaemonResponse } from '../../daemon/types.ts';
import type { Point } from '../../kernel/snapshot.ts';
import type { MaestroRuntimeInvoke, ReplayBaseRequest } from './runtime-support.ts';

export async function invokeMaestroClickPoint(params: {
baseReq: ReplayBaseRequest;
invoke: MaestroRuntimeInvoke;
point: Point;
}): Promise<DaemonResponse> {
return await params.invoke({
...params.baseReq,
command: 'click',
positionals: [String(params.point.x), String(params.point.y)],
flags: {
...params.baseReq.flags,
postGestureStabilization: true,
},
});
}
29 changes: 9 additions & 20 deletions src/compat/maestro/runtime-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
type ScrollDirection,
} from '../../core/scroll-gesture.ts';
import type { ReplayVarScope } from '../../replay/vars.ts';
import type { SnapshotState } from '../../kernel/snapshot.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import { sleep } from '../../utils/timeouts.ts';
import { invokeMaestroClickPoint } from './runtime-click.ts';
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtime-geometry.ts';
import {
captureMaestroSnapshot,
Expand Down Expand Up @@ -140,15 +142,7 @@ export async function invokeMaestroTapPointPercent(params: {
}

const point = pointFromPercent(frame, xPercent, yPercent);
const response = await params.invoke({
...params.baseReq,
command: 'click',
positionals: [String(point.x), String(point.y)],
flags: {
...params.baseReq.flags,
postGestureStabilization: true,
},
});
const response = await invokeMaestroClickPoint({ ...params, point });
if (response.ok) clearMaestroRecoverableInteraction(params.scope);
return response;
}
Expand Down Expand Up @@ -520,15 +514,7 @@ async function clickMaestroResolvedTarget(
point,
},
});
const response = await params.invoke({
...params.baseReq,
command: 'click',
positionals: [String(point.x), String(point.y)],
flags: {
...params.baseReq.flags,
postGestureStabilization: true,
},
});
const response = await invokeMaestroClickPoint({ ...params, point });
if (response.ok) {
clearMaestroVisibleContext(params.scope);
rememberMaestroRecoverableInteraction(params.scope, {
Expand Down Expand Up @@ -586,7 +572,8 @@ async function resolveMaestroInteractionTarget(
commandLabel: string,
resolutionOptions: { promoteTapTarget: boolean },
): Promise<
{ ok: true; target: ResolvedMaestroInteractionTarget } | { ok: false; response: DaemonResponse }
| { ok: true; target: ResolvedMaestroInteractionTarget; snapshot: SnapshotState }
| { ok: false; response: DaemonResponse }
> {
const snapshotResponse = await captureMaestroSnapshot({ ...params, raw: true });
return resolveMaestroInteractionTargetFromResponse(
Expand All @@ -610,7 +597,7 @@ function resolveMaestroInteractionTargetFromResponse(
resolutionOptions: { promoteTapTarget: boolean },
snapshotResponse: DaemonResponse,
):
| { ok: true; target: ResolvedMaestroInteractionTarget }
| { ok: true; target: ResolvedMaestroInteractionTarget; snapshot: SnapshotState }
| { ok: false; response: DaemonResponse } {
if (!snapshotResponse.ok) return { ok: false, response: snapshotResponse };
const snapshot = readSnapshotState(snapshotResponse.data);
Expand Down Expand Up @@ -650,6 +637,7 @@ function resolveMaestroInteractionTargetFromResponse(
rect: fuzzyResolution.rect,
frame,
},
snapshot,
};
}
}
Expand All @@ -671,6 +659,7 @@ function resolveMaestroInteractionTargetFromResponse(
rect: resolution.rect,
frame,
},
snapshot,
};
}

Expand Down
92 changes: 92 additions & 0 deletions src/daemon/handlers/__tests__/snapshot-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,98 @@ test('buildSnapshotState marks content covered by floating overlays as visible b
expect(state.nodes.some((node) => node.type === 'TabBar')).toBe(true);
});

test('buildSnapshotState marks Android app content covered by IME overlays as blocked', () => {
const state = buildSnapshotState(
{
nodes: [
{
index: 0,
depth: 0,
type: 'android.widget.FrameLayout',
bundleId: 'org.example',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'android.widget.Button',
label: 'Push Article',
bundleId: 'org.example',
rect: { x: 40, y: 600, width: 180, height: 56 },
hittable: true,
},
{
index: 2,
depth: 1,
type: 'android.widget.FrameLayout',
bundleId: 'com.google.android.inputmethod.latin',
rect: { x: 0, y: 400, width: 390, height: 444 },
},
],
backend: 'android',
},
undefined,
);

expect(state.nodes.find((node) => node.label === 'Push Article')).toMatchObject({
hittable: false,
interactionBlocked: 'covered',
presentationHints: ['covered'],
});
});

test('buildSnapshotState treats large Android IME subtrees as one overlay root', () => {
const imeChildren = Array.from({ length: 2000 }, (_, offset) => ({
index: offset + 3,
depth: 2,
parentIndex: 2,
type: 'android.widget.TextView',
label: `Keyboard suggestion ${offset}`,
bundleId: 'com.google.android.inputmethod.latin',
rect: { x: offset % 300, y: 500 + (offset % 200), width: 80, height: 32 },
}));

const state = buildSnapshotState(
{
nodes: [
{
index: 0,
depth: 0,
type: 'android.widget.FrameLayout',
bundleId: 'org.example',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'android.widget.Button',
label: 'Covered action',
bundleId: 'org.example',
rect: { x: 40, y: 620, width: 180, height: 56 },
hittable: true,
},
{
index: 2,
depth: 1,
type: 'android.widget.FrameLayout',
bundleId: 'com.google.android.inputmethod.latin',
rect: { x: 0, y: 400, width: 390, height: 444 },
},
...imeChildren,
],
backend: 'android',
},
undefined,
);

expect(state.nodes.find((node) => node.label === 'Covered action')).toMatchObject({
hittable: false,
interactionBlocked: 'covered',
});
});

test('buildSnapshotState does not treat later generic hittable containers as covers', () => {
const state = buildSnapshotState(
{
Expand Down
8 changes: 7 additions & 1 deletion src/daemon/handlers/snapshot-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { annotateCoveredSnapshotNodes } from '../../snapshot/snapshot-occlusion.ts';
import { normalizeSnapshotTree } from '../../snapshot/snapshot-tree.ts';
export { buildSnapshotVisibility } from '../../snapshot/snapshot-visibility.ts';
import { isAndroidInputMethodSnapshotNode } from '../../snapshot/android-input-method-overlays.ts';
import type { SessionState } from '../types.ts';
import {
ANDROID_FRESHNESS_RETRY_DEADLINE_MS,
Expand Down Expand Up @@ -402,7 +403,12 @@ export function buildSnapshotState(
? presentIosInteractiveSnapshot(scopedNodes)
: scopedNodes;
const nodes = attachRefs(
snapshotRaw ? presentableNodes : annotateCoveredSnapshotNodes(presentableNodes),
snapshotRaw
? presentableNodes
: annotateCoveredSnapshotNodes(presentableNodes, {
isAdditionalOverlayNode:
data?.backend === 'android' ? isAndroidInputMethodSnapshotNode : undefined,
}),
);
const snapshotQuality = snapshotCaptureAnnotationsFrom(data).quality;
return {
Expand Down
2 changes: 1 addition & 1 deletion src/platforms/android/__tests__/input-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
classifyAndroidInputOwnership,
parseAndroidInputMethodPackage,
readAndroidActiveInputMethodPackage,
} from '../input-ownership.ts';
} from '../../../core/android-input-ownership.ts';

test('classifies active input method package as IME-owned', () => {
assert.deepEqual(
Expand Down
Loading
Loading