Skip to content

Commit d46c758

Browse files
committed
fix: dispatch no-op tap retries as press
1 parent 87cfe81 commit d46c758

5 files changed

Lines changed: 59 additions & 60 deletions

File tree

‎src/daemon/__tests__/interaction-outcome-policy.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ test('markPendingInteractionOutcome stores retry state only for explicit retry f
5858
preSnapshot: makeSnapshot('Inbox'),
5959
});
6060

61-
assert.equal(retrySession.pendingInteractionOutcome?.command, 'click');
61+
assert.equal(retrySession.pendingInteractionOutcome?.action, 'click');
62+
assert.equal(retrySession.pendingInteractionOutcome?.command, 'press');
6263
assert.equal(retrySession.pendingInteractionOutcome?.attemptsRemaining, 2);
6364
assert.equal(retrySession.pendingInteractionOutcome?.flags?.interactionOutcome, undefined);
6465

‎src/daemon/handlers/__tests__/snapshot-handler.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,7 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr
742742
};
743743
session.pendingInteractionOutcome = {
744744
action: 'click',
745-
command: 'click',
745+
command: 'press',
746746
positionals: ['100', '144'],
747747
flags: { platform: 'ios' },
748748
markedAt: Date.now(),
@@ -796,7 +796,7 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr
796796
expect(result.snapshot.nodes).toEqual(
797797
expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]),
798798
);
799-
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'click', 'snapshot']);
799+
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press', 'snapshot']);
800800
expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['100', '144']);
801801
expect(session.pendingInteractionOutcome).toBeUndefined();
802802
});

‎src/daemon/handlers/snapshot-capture.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ async function captureInteractionOutcomeAwareSnapshot(
114114
let outcome = await retryPendingInteractionOutcome({
115115
session,
116116
pending,
117-
requestFlags: params.flags,
118117
logPath: params.logPath,
119118
snapshot: latest.snapshot,
120119
});
@@ -125,7 +124,6 @@ async function captureInteractionOutcomeAwareSnapshot(
125124
outcome = await retryPendingInteractionOutcome({
126125
session,
127126
pending,
128-
requestFlags: params.flags,
129127
logPath: params.logPath,
130128
snapshot: latest.snapshot,
131129
});

‎src/daemon/interaction-outcome-policy.ts‎

Lines changed: 48 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type InteractionSurfaceSignature = NonNullable<
1414

1515
export type InteractionSurfaceChange = 'changed' | 'unchanged' | 'ambiguous';
1616

17-
export function shouldRetryTouchOnNoChange(flags: CommandFlags | undefined): boolean {
17+
function shouldRetryTouchOnNoChange(flags: CommandFlags | undefined): boolean {
1818
return flags?.interactionOutcome?.retryOnNoChange === true;
1919
}
2020

@@ -28,13 +28,14 @@ export function markPendingInteractionOutcome(params: {
2828
const { session, command, positionals, flags, preSnapshot } = params;
2929
if (!shouldRetryTouchOnNoChange(flags)) return;
3030
if (!supportsInteractionOutcomePolicy(session)) return;
31-
if (!isRetryableTapCommand(command)) return;
31+
const retryCommand = retryCommandForTap(command);
32+
if (!retryCommand) return;
3233
if (!isCoordinatePair(positionals)) return;
3334
const preSignature = buildInteractionSurfaceSignature(preSnapshot?.nodes ?? []);
3435
if (preSignature.length === 0) return;
3536
session.pendingInteractionOutcome = {
3637
action: command,
37-
command,
38+
command: retryCommand,
3839
positionals,
3940
flags: stripInternalInteractionOutcomeFlags(flags),
4041
markedAt: Date.now(),
@@ -67,11 +68,10 @@ export function clearPendingInteractionOutcome(session: SessionState | undefined
6768
export async function retryPendingInteractionOutcome(params: {
6869
session: SessionState;
6970
pending: NonNullable<SessionState['pendingInteractionOutcome']>;
70-
requestFlags: CommandFlags | undefined;
7171
logPath: string;
7272
snapshot: SnapshotState;
7373
}): Promise<{ retried: boolean; change: InteractionSurfaceChange }> {
74-
const { session, pending, requestFlags, snapshot } = params;
74+
const { session, pending, snapshot } = params;
7575
const change = classifyInteractionSurfaceChange(
7676
pending.preSignature,
7777
buildInteractionSurfaceSignature(snapshot.nodes),
@@ -92,12 +92,7 @@ export async function retryPendingInteractionOutcome(params: {
9292
},
9393
});
9494
await dispatchCommand(session.device, pending.command, pending.positionals, pending.flags?.out, {
95-
...contextFromFlags(
96-
params.logPath,
97-
mergeRetryDispatchFlags(pending.flags, requestFlags),
98-
session.appBundleId,
99-
session.trace?.outPath,
100-
),
95+
...contextFromFlags(params.logPath, pending.flags, session.appBundleId, session.trace?.outPath),
10196
surface: session.surface,
10297
});
10398
return { retried: true, change };
@@ -156,31 +151,8 @@ export function buildInteractionSurfaceSignature(nodes: SnapshotNode[]): Array<{
156151
const entries: InteractionSurfaceSignature = [];
157152

158153
for (const node of nodes) {
159-
if (!node.rect) continue;
160-
if (!isFiniteRect(node.rect)) continue;
161-
if (isScrollIndicator(node)) continue;
162-
const semanticKey = [
163-
node.identifier,
164-
node.label,
165-
node.value,
166-
node.type,
167-
node.role,
168-
node.enabled === false ? 'disabled' : 'enabled',
169-
node.selected === true ? 'selected' : 'unselected',
170-
node.hittable === true ? 'hittable' : 'not-hittable',
171-
]
172-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
173-
.join('|');
174-
if (!semanticKey.replaceAll('|', '')) continue;
175-
const occurrence = occurrenceCounts.get(semanticKey) ?? 0;
176-
occurrenceCounts.set(semanticKey, occurrence + 1);
177-
entries.push({
178-
key: `${semanticKey}|#${occurrence}`,
179-
x: Math.round(node.rect.x),
180-
y: Math.round(node.rect.y),
181-
width: Math.round(node.rect.width),
182-
height: Math.round(node.rect.height),
183-
});
154+
const entry = buildInteractionSurfaceEntry(node, occurrenceCounts);
155+
if (entry) entries.push(entry);
184156
}
185157

186158
return entries;
@@ -212,26 +184,50 @@ export function areInteractionSurfaceSignaturesStable(
212184
return true;
213185
}
214186

215-
function mergeRetryDispatchFlags(
216-
pendingFlags: CommandFlags | undefined,
217-
requestFlags: CommandFlags | undefined,
218-
): CommandFlags | undefined {
219-
return {
220-
...(pendingFlags ?? {}),
221-
platform: requestFlags?.platform ?? pendingFlags?.platform,
222-
target: requestFlags?.target ?? pendingFlags?.target,
223-
device: requestFlags?.device ?? pendingFlags?.device,
224-
udid: requestFlags?.udid ?? pendingFlags?.udid,
225-
serial: requestFlags?.serial ?? pendingFlags?.serial,
226-
};
227-
}
228-
229187
function supportsInteractionOutcomePolicy(session: SessionState): boolean {
230188
return session.device.platform === 'ios' || session.device.platform === 'android';
231189
}
232190

233-
function isRetryableTapCommand(command: string): boolean {
234-
return command === 'click' || command === 'press';
191+
function retryCommandForTap(command: string): string | undefined {
192+
if (command === 'click') return 'press';
193+
if (command === 'press') return 'press';
194+
return undefined;
195+
}
196+
197+
function buildInteractionSurfaceEntry(
198+
node: SnapshotNode,
199+
occurrenceCounts: Map<string, number>,
200+
): InteractionSurfaceSignature[number] | undefined {
201+
if (!node.rect) return undefined;
202+
if (!isFiniteRect(node.rect)) return undefined;
203+
if (isScrollIndicator(node)) return undefined;
204+
const semanticKey = interactionSurfaceSemanticKey(node);
205+
if (!semanticKey) return undefined;
206+
const occurrence = occurrenceCounts.get(semanticKey) ?? 0;
207+
occurrenceCounts.set(semanticKey, occurrence + 1);
208+
return {
209+
key: `${semanticKey}|#${occurrence}`,
210+
x: Math.round(node.rect.x),
211+
y: Math.round(node.rect.y),
212+
width: Math.round(node.rect.width),
213+
height: Math.round(node.rect.height),
214+
};
215+
}
216+
217+
function interactionSurfaceSemanticKey(node: SnapshotNode): string | undefined {
218+
const semanticKey = [
219+
node.identifier,
220+
node.label,
221+
node.value,
222+
node.type,
223+
node.role,
224+
node.enabled === false ? 'disabled' : 'enabled',
225+
node.selected === true ? 'selected' : 'unselected',
226+
node.hittable === true ? 'hittable' : 'not-hittable',
227+
]
228+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
229+
.join('|');
230+
return semanticKey.replaceAll('|', '') ? semanticKey : undefined;
235231
}
236232

237233
function isCoordinatePair(positionals: string[]): boolean {

‎test/integration/provider-scenarios/android-test-suite.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,14 @@ test('Provider-backed integration Android Maestro executes runFlow conditions an
333333
assert.equal(suite.passed, 1, JSON.stringify(suite));
334334
assert.equal(suite.failed, 0, JSON.stringify(suite));
335335
assert.deepEqual(
336-
world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'),
337-
['shell', 'input', 'tap', '180', '330'],
336+
world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell input tap'),
337+
[
338+
['shell', 'input', 'tap', '180', '330'],
339+
['shell', 'input', 'tap', '180', '330'],
340+
['shell', 'input', 'tap', '180', '330'],
341+
],
338342
);
339-
assert.equal(snapshots, 3);
343+
assert.equal(snapshots, 5);
340344
},
341345
);
342346
});

0 commit comments

Comments
 (0)