Skip to content

Commit ea323f5

Browse files
committed
fix: preserve android freshness baseline for optimized taps
1 parent 21a149e commit ea323f5

7 files changed

Lines changed: 134 additions & 5 deletions

File tree

src/daemon/android-snapshot-freshness.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type { AndroidSnapshotFreshness } from './types.ts';
88
// and can lag behind real transitions by up to ~2 s; 2.5 s gives a comfortable margin
99
// while avoiding unnecessary retries for steady-state interactions like typing.
1010
const ANDROID_FRESHNESS_WINDOW_MS = 2_500;
11+
const ANDROID_COMPARISON_BASELINE_MAX_AGE_MS = 5_000;
1112

1213
// Retry suspicious snapshots until this post-action deadline expires. The delay
1314
// sequence stays short in the happy path; the 600 ms tail retry is opportunistic
@@ -28,21 +29,34 @@ export function markAndroidSnapshotFreshness(
2829
baseline = session.snapshot,
2930
): void {
3031
if (session.device.platform !== 'android') return;
32+
const comparisonBaseline = resolveAndroidComparisonBaseline(session, baseline);
3133
// Route-stuck recovery only makes sense against a baseline captured in a broad, comparable
3234
// shape. Interactive/scoped/depth-limited snapshots are still useful for users, but they are
3335
// too pruned to serve as a reliable "same route vs new route" baseline.
34-
const routeComparable = baseline?.comparisonSafe === true;
36+
const routeComparable = comparisonBaseline?.comparisonSafe === true;
3537
session.androidSnapshotFreshness = {
3638
action,
3739
markedAt: Date.now(),
38-
baselineCount: baseline?.nodes.length ?? 0,
40+
baselineCount: (comparisonBaseline ?? baseline)?.nodes.length ?? 0,
3941
baselineSignatures: routeComparable
40-
? buildSnapshotSignatures(baseline?.nodes ?? [])
42+
? buildSnapshotSignatures(comparisonBaseline?.nodes ?? [])
4143
: undefined,
4244
routeComparable,
4345
};
4446
}
4547

48+
function resolveAndroidComparisonBaseline(
49+
session: SessionState,
50+
baseline: SnapshotState | undefined,
51+
): SnapshotState | undefined {
52+
if (baseline?.comparisonSafe === true) return baseline;
53+
const previous = session.lastComparisonSafeSnapshot;
54+
if (!previous || previous.comparisonSafe !== true) return baseline;
55+
return Date.now() - previous.createdAt <= ANDROID_COMPARISON_BASELINE_MAX_AGE_MS
56+
? previous
57+
: baseline;
58+
}
59+
4660
export function getActiveAndroidSnapshotFreshness(
4761
session: SessionState | undefined,
4862
): AndroidSnapshotFreshness | undefined {

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,72 @@ test('press @ref falls back to cached Android ref when freshness refresh fails',
14461446
});
14471447
});
14481448

1449+
test('coordinate press preserves Android route freshness from last comparable snapshot', async () => {
1450+
const sessionStore = makeSessionStore();
1451+
const sessionName = 'android-coordinate-freshness-baseline';
1452+
const session = makeAndroidSession(sessionName);
1453+
const comparableSnapshot = {
1454+
nodes: attachRefs([
1455+
{
1456+
index: 0,
1457+
type: 'android.widget.ScrollView',
1458+
label: 'Albums',
1459+
rect: { x: 0, y: 0, width: 400, height: 700 },
1460+
},
1461+
{
1462+
index: 1,
1463+
type: 'android.widget.Button',
1464+
label: 'Go to Contacts',
1465+
rect: { x: 16, y: 120, width: 160, height: 48 },
1466+
enabled: true,
1467+
hittable: true,
1468+
},
1469+
]),
1470+
createdAt: Date.now(),
1471+
backend: 'android' as const,
1472+
comparisonSafe: true,
1473+
};
1474+
session.lastComparisonSafeSnapshot = comparableSnapshot;
1475+
session.snapshot = {
1476+
nodes: attachRefs([
1477+
{
1478+
index: 0,
1479+
type: 'android.widget.Button',
1480+
label: 'Go to Contacts',
1481+
rect: { x: 16, y: 120, width: 160, height: 48 },
1482+
enabled: true,
1483+
hittable: true,
1484+
},
1485+
]),
1486+
createdAt: Date.now(),
1487+
backend: 'android',
1488+
comparisonSafe: false,
1489+
};
1490+
sessionStore.set(sessionName, session);
1491+
mockDispatch.mockResolvedValue({ pressed: true });
1492+
1493+
const response = await handleInteractionCommands({
1494+
req: {
1495+
token: 't',
1496+
session: sessionName,
1497+
command: 'press',
1498+
positionals: ['96', '144'],
1499+
flags: {},
1500+
},
1501+
sessionName,
1502+
sessionStore,
1503+
contextFromFlags,
1504+
});
1505+
1506+
expect(response?.ok).toBe(true);
1507+
expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toMatchObject({
1508+
action: 'press',
1509+
baselineCount: 2,
1510+
baselineSignatures: expect.any(Array),
1511+
routeComparable: true,
1512+
});
1513+
});
1514+
14491515
test('press @ref fails when Android tap escapes to launcher', async () => {
14501516
const sessionStore = makeSessionStore();
14511517
const sessionName = 'android-escape';

src/daemon/handlers/__tests__/session-replay.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
25
import { test } from 'vitest';
36
import { buildNestedReplayFlags } from '../session-replay.ts';
7+
import { collectReplayActionArtifactPaths } from '../session-replay-runtime.ts';
48

59
test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => {
610
const parent = { platform: 'android' as const, timeoutMs: 5000 };
@@ -51,3 +55,22 @@ test('buildNestedReplayFlags overrides a parent artifactsDir with the attempt-le
5155
});
5256
assert.equal(result?.artifactsDir, '/suite-root/flow/attempt-2');
5357
});
58+
59+
test('collectReplayActionArtifactPaths includes failed action artifact details', () => {
60+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-artifacts-'));
61+
const snapshotPath = path.join(root, 'failure-snapshot.txt');
62+
fs.writeFileSync(snapshotPath, 'snapshot');
63+
64+
const paths = collectReplayActionArtifactPaths({
65+
ok: false,
66+
error: {
67+
code: 'COMMAND_FAILED',
68+
message: 'assertion failed',
69+
details: {
70+
artifactPaths: [snapshotPath, path.join(root, 'missing.txt')],
71+
},
72+
},
73+
});
74+
75+
assert.deepEqual(paths, [snapshotPath]);
76+
});

src/daemon/handlers/session-replay-runtime.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export async function runReplayScriptFile(params: {
102102
collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry));
103103
continue;
104104
}
105+
collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry));
105106
if (!shouldUpdate) {
106107
return withReplayFailureContext(response, action, index, resolved, [...artifactPaths]);
107108
}
@@ -129,6 +130,7 @@ export async function runReplayScriptFile(params: {
129130
invoke,
130131
});
131132
if (!response.ok) {
133+
collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry));
132134
return withReplayFailureContext(response, nextAction, index, resolved, [...artifactPaths]);
133135
}
134136
collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry));
@@ -231,7 +233,20 @@ function withReplayFailureContext(
231233

232234
// fallow-ignore-next-line complexity
233235
export function collectReplayActionArtifactPaths(response: DaemonResponse): string[] {
234-
if (!response.ok || !response.data) return [];
236+
if (!response.ok) {
237+
const paths = response.error.details?.artifactPaths;
238+
return Array.isArray(paths)
239+
? [
240+
...new Set(
241+
paths.filter(
242+
(candidate): candidate is string =>
243+
typeof candidate === 'string' && isReplayArtifactPath(candidate),
244+
),
245+
),
246+
]
247+
: [];
248+
}
249+
if (!response.data) return [];
235250
const candidates: string[] = [];
236251
if (typeof response.data.path === 'string') candidates.push(response.data.path);
237252
if (typeof response.data.outPath === 'string') candidates.push(response.data.outPath);

src/daemon/handlers/snapshot-session.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,20 @@ export function buildSnapshotSession(params: {
5656
}): SessionState {
5757
const { session, sessionName, device, snapshot, appBundleId } = params;
5858
if (session) {
59-
return { ...session, snapshot };
59+
return {
60+
...session,
61+
snapshot,
62+
lastComparisonSafeSnapshot:
63+
snapshot?.comparisonSafe === true ? snapshot : session.lastComparisonSafeSnapshot,
64+
};
6065
}
6166
return {
6267
name: sessionName,
6368
device,
6469
createdAt: Date.now(),
6570
appBundleId,
6671
snapshot,
72+
...(snapshot?.comparisonSafe === true ? { lastComparisonSafeSnapshot: snapshot } : {}),
6773
actions: [],
6874
};
6975
}

src/daemon/session-snapshot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ import type { SessionState } from './types.ts';
44
export function setSessionSnapshot(session: SessionState, snapshot: SnapshotState): void {
55
session.snapshot = snapshot;
66
session.snapshotScopeSource = undefined;
7+
if (snapshot.comparisonSafe === true) {
8+
session.lastComparisonSafeSnapshot = snapshot;
9+
}
710
}

src/daemon/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ export type SessionState = {
211211
snapshot?: SnapshotState;
212212
/** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */
213213
snapshotScopeSource?: SnapshotState;
214+
/** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */
215+
lastComparisonSafeSnapshot?: SnapshotState;
214216
androidSnapshotFreshness?: AndroidSnapshotFreshness;
215217
postGestureStabilization?: PostGestureStabilization;
216218
pendingInteractionOutcome?: PendingInteractionOutcome;

0 commit comments

Comments
 (0)