Skip to content

Commit 89a907c

Browse files
committed
fix: tighten Android freshness baselines
1 parent 94813de commit 89a907c

11 files changed

Lines changed: 136 additions & 45 deletions

src/daemon/android-snapshot-freshness.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,32 @@ export type AndroidSnapshotFreshness = {
99
action: string;
1010
markedAt: number;
1111
baselineCount: number;
12-
baselineSignatures: string[];
12+
baselineSignatures?: string[];
13+
routeComparable: boolean;
1314
};
1415

1516
export type AndroidFreshnessCaptureMeta = {
1617
action: string;
1718
retryCount: number;
1819
staleAfterRetries: boolean;
20+
reason?: 'empty-interactive' | 'sharp-drop' | 'stuck-route';
1921
};
2022

21-
export function markAndroidSnapshotFreshness(session: SessionState, action: string): void {
23+
export function markAndroidSnapshotFreshness(
24+
session: SessionState,
25+
action: string,
26+
baseline = session.snapshot,
27+
): void {
2228
if (session.device.platform !== 'android') return;
29+
const routeComparable = baseline?.comparisonSafe === true;
2330
session.androidSnapshotFreshness = {
2431
action,
2532
markedAt: Date.now(),
26-
baselineCount: session.snapshot?.nodes.length ?? 0,
27-
baselineSignatures: buildSnapshotSignatures(session.snapshot?.nodes ?? []),
33+
baselineCount: baseline?.nodes.length ?? 0,
34+
baselineSignatures: routeComparable
35+
? buildSnapshotSignatures(baseline?.nodes ?? [])
36+
: undefined,
37+
routeComparable,
2838
};
2939
}
3040

@@ -74,9 +84,12 @@ export function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: n
7484
}
7585

7686
export function isLikelySnapshotStuckOnPreviousRoute(
77-
previousSignatures: string[],
87+
previousSignatures: string[] | undefined,
7888
currentNodes: SnapshotState['nodes'],
7989
): boolean {
90+
if (!previousSignatures || previousSignatures.length === 0) {
91+
return false;
92+
}
8093
const total = Math.max(previousSignatures.length, currentNodes.length);
8194
if (total < 12) {
8295
return false;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,14 @@ test('handleFindCommands wait bypasses snapshot cache while Android freshness re
319319
nodes: baselineNodes,
320320
createdAt: Date.now(),
321321
backend: 'android',
322+
comparisonSafe: true,
322323
};
323324
session.androidSnapshotFreshness = {
324325
action: 'press',
325326
markedAt: Date.now(),
326327
baselineCount: baselineNodes.length,
327328
baselineSignatures: buildSnapshotSignatures(baselineNodes),
329+
routeComparable: true,
328330
};
329331

330332
mockDispatch

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async function emulateCaptureSnapshotForSession(
6969
effectiveFlags.out,
7070
contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath),
7171
)) as { nodes?: never[]; truncated?: boolean; backend?: 'xctest' | 'android' | 'macos-helper' };
72-
const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags.snapshotRaw);
72+
const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags);
7373
session.snapshot = snapshot;
7474
sessionStore.set(session.name, session);
7575
return snapshot;

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import fs from 'node:fs';
7878
import os from 'node:os';
7979
import path from 'node:path';
8080
import { handleSessionCommands } from '../session.ts';
81+
import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts';
8182
import {
8283
retainMaterializedPaths,
8384
cleanupRetainedMaterializedPathsForSession,
@@ -2624,6 +2625,63 @@ test('open intent target on existing Android session clears stale package contex
26242625
expect(dispatchedContext?.appBundleId).toBe(undefined);
26252626
});
26262627

2628+
test('open on existing Android session preserves a comparable freshness baseline', async () => {
2629+
const sessionStore = makeSessionStore();
2630+
const sessionName = 'android-open-freshness';
2631+
const baselineNodes = Array.from({ length: 14 }, (_, index) => ({
2632+
ref: `e${index + 1}`,
2633+
index,
2634+
depth: 0,
2635+
type: 'android.widget.TextView',
2636+
label: `Inbox row ${index + 1}`,
2637+
}));
2638+
sessionStore.set(sessionName, {
2639+
...makeSession(sessionName, {
2640+
platform: 'android',
2641+
id: 'emulator-5554',
2642+
name: 'Pixel Emulator',
2643+
kind: 'emulator',
2644+
booted: true,
2645+
}),
2646+
appBundleId: 'com.example.old',
2647+
appName: 'Old App',
2648+
snapshot: {
2649+
nodes: baselineNodes,
2650+
createdAt: Date.now(),
2651+
backend: 'android',
2652+
comparisonSafe: true,
2653+
},
2654+
});
2655+
2656+
mockDispatch.mockResolvedValue({});
2657+
mockResolveAndroidPackage.mockResolvedValue('com.android.settings');
2658+
2659+
const response = await handleSessionCommands({
2660+
req: {
2661+
token: 't',
2662+
session: sessionName,
2663+
command: 'open',
2664+
positionals: ['settings'],
2665+
flags: {},
2666+
},
2667+
sessionName,
2668+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2669+
sessionStore,
2670+
invoke: noopInvoke,
2671+
});
2672+
2673+
expect(response?.ok).toBe(true);
2674+
const updated = sessionStore.get(sessionName);
2675+
expect(updated?.snapshot).toBeUndefined();
2676+
expect(updated?.androidSnapshotFreshness).toEqual({
2677+
action: 'open',
2678+
markedAt: expect.any(Number),
2679+
baselineCount: baselineNodes.length,
2680+
baselineSignatures: buildSnapshotSignatures(baselineNodes),
2681+
routeComparable: true,
2682+
});
2683+
});
2684+
26272685
test('open --relaunch closes and reopens active session app', async () => {
26282686
const sessionStore = makeSessionStore();
26292687
const sessionName = 'android-session';

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,14 @@ test('snapshot automatically retries stale Android trees after recent navigation
213213
nodes: baselineNodes,
214214
createdAt: Date.now(),
215215
backend: 'android',
216+
comparisonSafe: true,
216217
};
217218
session.androidSnapshotFreshness = {
218219
action: 'press',
219220
markedAt: Date.now(),
220221
baselineCount: baselineNodes.length,
221222
baselineSignatures: buildSnapshotSignatures(baselineNodes),
223+
routeComparable: true,
222224
};
223225
sessionStore.set(sessionName, session);
224226

@@ -283,12 +285,14 @@ test('snapshot warns when Android freshness retries still return the previous ro
283285
nodes: baselineNodes,
284286
createdAt: Date.now(),
285287
backend: 'android',
288+
comparisonSafe: true,
286289
};
287290
session.androidSnapshotFreshness = {
288291
action: 'press',
289292
markedAt: Date.now(),
290293
baselineCount: baselineNodes.length,
291294
baselineSignatures: buildSnapshotSignatures(baselineNodes),
295+
routeComparable: true,
292296
};
293297
sessionStore.set(sessionName, session);
294298

@@ -343,12 +347,14 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy
343347
nodes: baselineNodes,
344348
createdAt: Date.now(),
345349
backend: 'android',
350+
comparisonSafe: true,
346351
};
347352
session.androidSnapshotFreshness = {
348353
action: 'press',
349354
markedAt: Date.now(),
350355
baselineCount: baselineNodes.length,
351356
baselineSignatures: buildSnapshotSignatures(baselineNodes),
357+
routeComparable: true,
352358
};
353359
sessionStore.set(sessionName, session);
354360

@@ -403,12 +409,14 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap
403409
nodes: baselineNodes,
404410
createdAt: Date.now(),
405411
backend: 'android',
412+
comparisonSafe: true,
406413
};
407414
session.androidSnapshotFreshness = {
408415
action: 'press',
409416
markedAt: Date.now(),
410417
baselineCount: baselineNodes.length,
411418
baselineSignatures: buildSnapshotSignatures(baselineNodes),
419+
routeComparable: true,
412420
};
413421
sessionStore.set(sessionName, session);
414422

src/daemon/handlers/find.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@ import { parseTimeout } from './parse-utils.ts';
1111
import { readTextForNode } from './interaction-read.ts';
1212
import { captureSnapshot } from './snapshot-capture.ts';
1313
import { errorResponse } from './response.ts';
14-
import {
15-
getActiveAndroidSnapshotFreshness,
16-
markAndroidSnapshotFreshness,
17-
} from '../android-snapshot-freshness.ts';
14+
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
1815

1916
type FindContext = {
2017
req: DaemonRequest;
@@ -263,7 +260,6 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise<
263260
matchData.y = matchCoords.y;
264261
}
265262
if (session) {
266-
markAndroidSnapshotFreshness(session, 'click');
267263
sessionStore.recordAction(session, {
268264
command,
269265
positionals: req.positionals ?? [],

src/daemon/handlers/session-open.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ async function completeOpenCommand(params: {
157157
};
158158
}
159159

160+
if (existingSession) {
161+
markAndroidSnapshotFreshness(existingSession, 'open', existingSession.snapshot);
162+
}
160163
const nextSession = buildNextOpenSession({
161164
existingSession,
162165
sessionName,
@@ -186,7 +189,6 @@ async function completeOpenCommand(params: {
186189
runtime: req.runtime !== undefined ? runtime : undefined,
187190
result: openResult,
188191
});
189-
markAndroidSnapshotFreshness(nextSession, 'open');
190192
sessionStore.set(sessionName, nextSession);
191193
return { ok: true, data: openResult };
192194
}

src/daemon/handlers/snapshot-capture.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type SnapshotData = {
3737
analysis?: AndroidSnapshotAnalysis;
3838
};
3939

40+
type AndroidFreshnessReason = 'empty-interactive' | 'sharp-drop' | 'stuck-route';
41+
4042
export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{
4143
snapshot: SnapshotState;
4244
analysis?: AndroidSnapshotAnalysis;
@@ -49,7 +51,7 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{
4951
const data = await captureSnapshotData(params);
5052
clearAndroidSnapshotFreshness(params.session);
5153
return {
52-
snapshot: buildSnapshotState(data, params.flags?.snapshotRaw),
54+
snapshot: buildSnapshotState(data, params.flags),
5355
analysis: data.analysis,
5456
};
5557
}
@@ -85,30 +87,31 @@ async function captureAndroidFreshnessAwareSnapshot(
8587
freshness?: AndroidFreshnessCaptureMeta;
8688
}> {
8789
let latest = await captureSnapshotAttempt(params);
88-
let suspicious = isSuspiciousAndroidFreshnessCapture(latest, freshness, params.flags);
90+
let suspiciousReason = getAndroidFreshnessReason(latest, freshness, params.flags);
8991
let retryCount = 0;
9092

9193
for (const delayMs of ANDROID_FRESHNESS_RETRY_DELAYS_MS) {
92-
if (!suspicious) break;
94+
if (!suspiciousReason) break;
9395
await new Promise((resolve) => setTimeout(resolve, delayMs));
9496
latest = await captureSnapshotAttempt(params);
9597
retryCount += 1;
96-
suspicious = isSuspiciousAndroidFreshnessCapture(latest, freshness, params.flags);
98+
suspiciousReason = getAndroidFreshnessReason(latest, freshness, params.flags);
9799
}
98100

99-
if (!suspicious) {
101+
if (!suspiciousReason) {
100102
clearAndroidSnapshotFreshness(params.session);
101103
}
102104

103105
return {
104106
snapshot: latest.snapshot,
105107
analysis: latest.data.analysis,
106108
freshness:
107-
retryCount > 0 || suspicious
109+
retryCount > 0 || Boolean(suspiciousReason)
108110
? {
109111
action: freshness.action,
110112
retryCount,
111-
staleAfterRetries: suspicious,
113+
staleAfterRetries: Boolean(suspiciousReason),
114+
reason: suspiciousReason ?? undefined,
112115
}
113116
: undefined,
114117
};
@@ -120,15 +123,15 @@ async function captureSnapshotAttempt(
120123
const data = await captureSnapshotData(params);
121124
return {
122125
data,
123-
snapshot: buildSnapshotState(data, params.flags?.snapshotRaw),
126+
snapshot: buildSnapshotState(data, params.flags),
124127
};
125128
}
126129

127-
function isSuspiciousAndroidFreshnessCapture(
130+
function getAndroidFreshnessReason(
128131
attempt: { data: SnapshotData; snapshot: SnapshotState },
129132
freshness: NonNullable<SessionState['androidSnapshotFreshness']>,
130133
flags: CommandFlags | undefined,
131-
): boolean {
134+
): AndroidFreshnessReason | null {
132135
const interactiveOnly = flags?.snapshotInteractiveOnly === true;
133136
const analysis = attempt.data.analysis;
134137

@@ -138,17 +141,18 @@ function isSuspiciousAndroidFreshnessCapture(
138141
analysis &&
139142
analysis.rawNodeCount >= 12
140143
) {
141-
return true;
144+
return 'empty-interactive';
142145
}
143146

144147
if (isLikelyStaleSnapshotDrop(freshness.baselineCount, attempt.snapshot.nodes.length)) {
145-
return !hasMeaningfulSnapshotContent(attempt.snapshot);
148+
return !hasMeaningfulSnapshotContent(attempt.snapshot) ? 'sharp-drop' : null;
146149
}
147150

148-
return (
151+
return freshness.routeComparable &&
149152
isNavigationSensitiveAction(freshness.action) &&
150153
isLikelySnapshotStuckOnPreviousRoute(freshness.baselineSignatures, attempt.snapshot.nodes)
151-
);
154+
? 'stuck-route'
155+
: null;
152156
}
153157

154158
function hasMeaningfulSnapshotContent(snapshot: SnapshotState): boolean {
@@ -167,15 +171,28 @@ export function buildSnapshotState(
167171
truncated?: boolean;
168172
backend?: 'xctest' | 'android' | 'macos-helper';
169173
},
170-
snapshotRaw: boolean | undefined,
174+
flags:
175+
| (Pick<
176+
CommandFlags,
177+
'snapshotCompact' | 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw'
178+
> &
179+
Partial<Pick<CommandFlags, 'snapshotScope'>>)
180+
| undefined,
171181
): SnapshotState {
172182
const rawNodes = data?.nodes ?? [];
183+
const snapshotRaw = flags?.snapshotRaw;
173184
const nodes = attachRefs(snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
174185
return {
175186
nodes,
176187
truncated: data?.truncated,
177188
createdAt: Date.now(),
178189
backend: data?.backend,
190+
comparisonSafe:
191+
data?.backend === 'android' &&
192+
flags?.snapshotInteractiveOnly !== true &&
193+
flags?.snapshotCompact !== true &&
194+
typeof flags?.snapshotDepth !== 'number' &&
195+
!flags?.snapshotScope,
179196
};
180197
}
181198

0 commit comments

Comments
 (0)