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
5 changes: 5 additions & 0 deletions src/compat/maestro/runtime-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ async function clickMaestroSnapshotTarget(
...params.baseReq,
command: 'click',
positionals: [String(point.x), String(point.y)],
flags: {
...params.baseReq.flags,
interactionOutcome: { retryOnNoChange: true },
},
});
if (response.ok) clearMaestroVisibleContext(params.scope);
return {
Expand Down Expand Up @@ -550,6 +554,7 @@ async function invokeMaestroFuzzyTapOn(
flags: {
...params.baseReq.flags,
findFirst: true,
interactionOutcome: { retryOnNoChange: true },
},
});
if (findResponse.ok) return { retry: false, response: findResponse };
Expand Down
3 changes: 3 additions & 0 deletions src/core/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type MaestroRuntimeFlags = {
export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
batchSteps?: DaemonBatchStep[];
clearAppState?: boolean;
interactionOutcome?: {
retryOnNoChange?: boolean;
};
launchArgs?: string[];
maestro?: MaestroRuntimeFlags;
replayBackend?: string;
Expand Down
129 changes: 129 additions & 0 deletions src/daemon/__tests__/interaction-outcome-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { SnapshotState } from '../../utils/snapshot.ts';
import {
buildInteractionSurfaceSignature,
classifyInteractionSurfaceChange,
markPendingInteractionOutcome,
stripInternalInteractionOutcomeFlags,
} from '../interaction-outcome-policy.ts';
import type { SessionState } from '../types.ts';
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';

test('classifyInteractionSurfaceChange treats identical surfaces as unchanged', () => {
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);

assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged');
});

test('classifyInteractionSurfaceChange tolerates tiny rect drift', () => {
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes);
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100.4).nodes);

assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged');
});

test('classifyInteractionSurfaceChange detects semantic screen changes', () => {
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);
const after = buildInteractionSurfaceSignature(makeSnapshot('Article detail').nodes);

assert.equal(classifyInteractionSurfaceChange(before, after), 'changed');
});

test('classifyInteractionSurfaceChange detects material layout movement', () => {
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes);
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 180).nodes);

assert.equal(classifyInteractionSurfaceChange(before, after), 'changed');
});

test('markPendingInteractionOutcome stores retry state only for explicit retry flags', () => {
const session = makeSession();
markPendingInteractionOutcome({
session,
command: 'click',
positionals: ['20', '40'],
flags: {},
preSnapshot: makeSnapshot('Inbox'),
});
assert.equal(session.pendingInteractionOutcome, undefined);

const retrySession = makeSession();
markPendingInteractionOutcome({
session: retrySession,
command: 'click',
positionals: ['20', '40'],
flags: { interactionOutcome: { retryOnNoChange: true } },
preSnapshot: makeSnapshot('Inbox'),
});

assert.equal(retrySession.pendingInteractionOutcome?.action, 'click');
assert.equal(retrySession.pendingInteractionOutcome?.command, 'press');
assert.equal(retrySession.pendingInteractionOutcome?.attemptsRemaining, 2);
assert.equal(retrySession.pendingInteractionOutcome?.flags?.interactionOutcome, undefined);

const refSession = makeSession();
markPendingInteractionOutcome({
session: refSession,
command: 'click',
positionals: ['@e1'],
flags: { interactionOutcome: { retryOnNoChange: true } },
preSnapshot: makeSnapshot('Inbox'),
});
assert.equal(refSession.pendingInteractionOutcome, undefined);

const longPressSession = makeSession();
markPendingInteractionOutcome({
session: longPressSession,
command: 'longpress',
positionals: ['20', '40', '800'],
flags: { interactionOutcome: { retryOnNoChange: true } },
preSnapshot: makeSnapshot('Inbox'),
});
assert.equal(longPressSession.pendingInteractionOutcome, undefined);
});

test('stripInternalInteractionOutcomeFlags removes internal retry controls', () => {
assert.deepEqual(
stripInternalInteractionOutcomeFlags({
platform: 'ios',
interactionOutcome: { retryOnNoChange: true },
}),
{ platform: 'ios' },
);
});

function makeSession(): SessionState {
return {
name: 'ios',
device: IOS_SIMULATOR,
createdAt: Date.now(),
actions: [],
};
}

function makeSnapshot(label: string, y = 100): SnapshotState {
return {
nodes: [
{
ref: 'e1',
index: 0,
type: 'Application',
label: 'App',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
ref: 'e2',
index: 1,
parentIndex: 0,
type: 'Button',
identifier: 'primary-action',
label,
rect: { x: 120, y, width: 80, height: 40 },
},
],
createdAt: Date.now(),
backend: 'xctest',
};
}
36 changes: 34 additions & 2 deletions src/daemon/handlers/__tests__/find.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ async function runFindClickScenario(options: {
}): Promise<{
response: NonNullable<Awaited<ReturnType<typeof handleFindCommands>>>;
invokeCalls: DaemonRequest[];
session: SessionState;
}> {
const sessionStore = makeSessionStore();
const sessionName = 'default';
sessionStore.set(sessionName, options.session ?? makeSession(sessionName));
const session = options.session ?? makeSession(sessionName);
sessionStore.set(sessionName, session);

if (options.nodes !== undefined) {
mockDispatch.mockImplementation(async (_device, command) => {
Expand Down Expand Up @@ -70,7 +72,7 @@ async function runFindClickScenario(options: {
});

expect(response).toBeTruthy();
return { response: response!, invokeCalls };
return { response: response!, invokeCalls, session };
}

test('handleFindCommands click returns deterministic metadata across locator variants', async () => {
Expand Down Expand Up @@ -213,6 +215,36 @@ test('handleFindCommands click prefers semantic controls over matching container
expect(invokeCalls[0]!.positionals?.[0]).toBe('@e5');
});

test('handleFindCommands forwards internal interaction outcome flags only to delegated click', async () => {
const { response, invokeCalls, session } = await runFindClickScenario({
positionals: ['Continue', 'click'],
flags: {
findFirst: true,
interactionOutcome: { retryOnNoChange: true },
},
nodes: [
{
index: 0,
ref: 'e1',
type: 'Application',
rect: { x: 0, y: 0, width: 440, height: 956 },
},
{
index: 1,
ref: 'e2',
type: 'Button',
label: 'Continue',
rect: { x: 40, y: 870, width: 360, height: 44 },
parentIndex: 0,
},
],
});

expect(response.ok).toBe(true);
expect(invokeCalls[0]!.flags?.interactionOutcome).toEqual({ retryOnNoChange: true });
expect(session.actions.at(-1)?.flags).toEqual({});
});

test('handleFindCommands wait bypasses snapshot cache while Android freshness recovery is active', async () => {
const sessionName = 'android-find-wait';
const session: SessionState = {
Expand Down
43 changes: 43 additions & 0 deletions src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,49 @@ test('press @ref preserves native timing in recorded result and touch visualizat
expect(stored?.recording?.gestureEvents[0]?.tMs).toBe(570);
});

test('press @ref stores resolved coordinate retry payload for lazy outcome retry', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'retry-ref';
const session = makeSession(sessionName);
session.snapshot = {
nodes: attachRefs([
{
index: 0,
type: 'XCUIElementTypeButton',
label: 'Continue',
identifier: 'auth_continue',
rect: { x: 10, y: 20, width: 100, height: 40 },
enabled: true,
hittable: true,
},
]),
createdAt: Date.now(),
backend: 'xctest',
};
sessionStore.set(sessionName, session);
mockDispatch.mockResolvedValue({});

const response = await handleInteractionCommands({
req: {
token: 't',
session: sessionName,
command: 'press',
positionals: ['@e1'],
flags: { interactionOutcome: { retryOnNoChange: true } },
},
sessionName,
sessionStore,
contextFromFlags,
});

expect(response?.ok).toBe(true);
const stored = sessionStore.get(sessionName);
expect(stored?.pendingInteractionOutcome?.command).toBe('press');
expect(stored?.pendingInteractionOutcome?.positionals).toEqual(['60', '40']);
expect(stored?.actions[0]?.positionals).toEqual(['@e1']);
expect(stored?.actions[0]?.flags).toEqual({});
});

test('longpress @ref resolves the target and dispatches coordinate longpress', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'longpress-ref';
Expand Down
6 changes: 6 additions & 0 deletions src/daemon/handlers/__tests__/session-replay-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,12 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen
['find', ['Continue', 'click']],
],
);
assert.deepEqual(calls.find((call) => call.command === 'click')?.flags?.interactionOutcome, {
retryOnNoChange: true,
});
assert.deepEqual(calls.find((call) => call.command === 'find')?.flags?.interactionOutcome, {
retryOnNoChange: true,
});
});

test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => {
Expand Down
Loading
Loading