Skip to content

Commit 5e788f6

Browse files
thymikeeclaude
andauthored
Refactor command handlers into specialized functions (#346)
* fix: document magic thresholds, fix BFS perf, and add edge-case tests Address code review findings from #345: - Document all undocumented heuristic constants across 11 files with rationale comments (freshness windows, retry delays, stale detection thresholds, safe-band zones, viewport-sized checks, scroll indicator boundaries, network log scan radii, nav subtree detection thresholds) - Fix O(n²) BFS in ui-hierarchy.ts findScopeNode — replace shift() with index-based queue traversal - Remove unused variable in ios/devices.ts listAppleDevices - Document bundletool cache thread-safety assumption in app-lifecycle.ts - Add 7 edge-case tests to mobile-snapshot-semantics.test.ts: zero-width viewport, zero-height viewport, negative coordinates, exact boundary visibility (both visible and outside cases) - Add 2 boundary-condition tests to output.test.ts for nav subtree detection thresholds (below node floor, below duplicate floor) All 1112 tests pass. https://claude.ai/code/session_014aYgMag5ziyhXZoXcP9a7i * refactor: decompose large handler functions for readability Extract handler functions from three monolithic files: - dispatch.ts: 13 command handlers from 786-line switch statement - request-router.ts: split executeSessionRequest into focused helpers - session-observability.ts: split into per-command handler functions https://claude.ai/code/session_014aYgMag5ziyhXZoXcP9a7i * test: add malformed snapshot data coverage for buildSnapshotState/buildSnapshotVisibility Closes the remaining acceptance gap in #345: tests for undefined nodes, sparse node fields, filtered-vs-unfiltered comparisonSafe semantics, raw/macos-helper backend bypass, and scroll-hint visibility detection. https://claude.ai/code/session_014aYgMag5ziyhXZoXcP9a7i --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ec465a commit 5e788f6

19 files changed

Lines changed: 1698 additions & 1028 deletions

src/core/dispatch.ts

Lines changed: 683 additions & 596 deletions
Large diffs are not rendered by default.

src/daemon/android-snapshot-freshness.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import type { SnapshotState } from '../utils/snapshot.ts';
22
import type { SessionState } from './types.ts';
33

4+
// How long after a navigation-sensitive action (press, click, back, open) to consider
5+
// the Android UI hierarchy potentially stale. Android's UIAutomator dump is async
6+
// and can lag behind real transitions by up to ~2 s; 2.5 s gives a comfortable margin
7+
// while avoiding unnecessary retries for steady-state interactions like typing.
48
const ANDROID_FRESHNESS_WINDOW_MS = 2_500;
59

10+
// Progressive back-off delays between freshness retry attempts. Two retries at
11+
// 250 ms + 400 ms cover the vast majority of Android transition latencies without
12+
// adding perceptible lag to the happy path.
613
export const ANDROID_FRESHNESS_RETRY_DELAYS_MS = [250, 400] as const;
714

815
export type AndroidSnapshotFreshness = {
@@ -82,6 +89,9 @@ export function buildSnapshotSignatures(nodes: SnapshotState['nodes']): string[]
8289
);
8390
}
8491

92+
// A snapshot whose node count dropped to ≤20% of the previous capture is likely a
93+
// stale or mid-transition dump. The 12-node floor prevents false positives on
94+
// already-tiny trees where fluctuation is normal.
8595
export function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: number): boolean {
8696
if (previousCount < 12) {
8797
return false;
@@ -97,6 +107,8 @@ export function isLikelySnapshotStuckOnPreviousRoute(
97107
return false;
98108
}
99109
const total = Math.max(previousSignatures.length, currentNodes.length);
110+
// Trees smaller than 12 nodes are too small for meaningful route comparison —
111+
// minor UI updates can produce high overlap percentages by coincidence.
100112
if (total < 12) {
101113
return false;
102114
}
@@ -110,6 +122,9 @@ export function isLikelySnapshotStuckOnPreviousRoute(
110122
}
111123
const additions = Math.max(0, currentSignatures.length - previousSignatures.length);
112124
const removals = Math.max(0, previousSignatures.length - currentSignatures.length);
125+
// Consider the snapshot "stuck" when ≥90% of nodes are identical and the number of
126+
// additions/removals stays within 15% (or at least 3). These thresholds accommodate
127+
// minor dynamic content (clocks, counters) while still detecting genuine route changes.
113128
const toleratedDelta = Math.max(3, Math.floor(total * 0.15));
114129
return (
115130
unchanged >= Math.floor(total * 0.9) &&

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55
import { handleSnapshotCommands } from '../snapshot.ts';
6+
import { buildSnapshotState, buildSnapshotVisibility } from '../snapshot-capture.ts';
67
import { SessionStore } from '../../session-store.ts';
78
import type { SessionState } from '../../types.ts';
89
import { AppError } from '../../../utils/errors.ts';
@@ -1332,3 +1333,128 @@ test('wait sleep bypasses sessionless runner cleanup wrapper', async () => {
13321333
expect(response).toBeTruthy();
13331334
expect(response?.ok).toBe(true);
13341335
});
1336+
1337+
// ---------------------------------------------------------------------------
1338+
// Malformed snapshot data – buildSnapshotState robustness
1339+
// ---------------------------------------------------------------------------
1340+
1341+
test('buildSnapshotState handles undefined nodes gracefully', () => {
1342+
const state = buildSnapshotState({ nodes: undefined, truncated: undefined }, undefined);
1343+
expect(state.nodes).toEqual([]);
1344+
expect(state.truncated).toBeUndefined();
1345+
expect(state.createdAt).toBeGreaterThan(0);
1346+
});
1347+
1348+
test('buildSnapshotState handles completely empty data object', () => {
1349+
const state = buildSnapshotState({}, undefined);
1350+
expect(state.nodes).toEqual([]);
1351+
expect(state.truncated).toBeUndefined();
1352+
});
1353+
1354+
test('buildSnapshotState handles nodes with missing fields', () => {
1355+
const state = buildSnapshotState(
1356+
{
1357+
nodes: [
1358+
{ index: 0 } as any,
1359+
{ index: 1, depth: undefined, type: undefined, label: undefined } as any,
1360+
],
1361+
truncated: false,
1362+
backend: 'android',
1363+
},
1364+
undefined,
1365+
);
1366+
expect(state.nodes).toHaveLength(2);
1367+
// Nodes should get refs assigned even with sparse data
1368+
expect(state.nodes[0]?.ref).toBeTruthy();
1369+
expect(state.nodes[1]?.ref).toBeTruthy();
1370+
});
1371+
1372+
test('buildSnapshotState marks comparisonSafe false for filtered Android snapshots', () => {
1373+
const nodes = [{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'A' }];
1374+
1375+
const interactiveOnly = buildSnapshotState(
1376+
{ nodes, backend: 'android' },
1377+
{ snapshotInteractiveOnly: true },
1378+
);
1379+
expect(interactiveOnly.comparisonSafe).toBe(false);
1380+
1381+
const compact = buildSnapshotState({ nodes, backend: 'android' }, { snapshotCompact: true });
1382+
expect(compact.comparisonSafe).toBe(false);
1383+
1384+
const withDepth = buildSnapshotState({ nodes, backend: 'android' }, { snapshotDepth: 2 });
1385+
expect(withDepth.comparisonSafe).toBe(false);
1386+
1387+
const withScope = buildSnapshotState({ nodes, backend: 'android' }, { snapshotScope: 'Header' });
1388+
expect(withScope.comparisonSafe).toBe(false);
1389+
1390+
const unfiltered = buildSnapshotState({ nodes, backend: 'android' }, {});
1391+
expect(unfiltered.comparisonSafe).toBe(true);
1392+
});
1393+
1394+
test('buildSnapshotState marks comparisonSafe false for non-Android backends', () => {
1395+
const nodes = [{ index: 0, depth: 0, type: 'Button', label: 'OK' }];
1396+
const state = buildSnapshotState({ nodes, backend: 'xctest' }, {});
1397+
expect(state.comparisonSafe).toBe(false);
1398+
});
1399+
1400+
// ---------------------------------------------------------------------------
1401+
// Malformed snapshot data – buildSnapshotVisibility robustness
1402+
// ---------------------------------------------------------------------------
1403+
1404+
test('buildSnapshotVisibility returns non-partial for empty node list', () => {
1405+
const vis = buildSnapshotVisibility({ nodes: [], backend: 'android' });
1406+
expect(vis.partial).toBe(false);
1407+
expect(vis.visibleNodeCount).toBe(0);
1408+
expect(vis.totalNodeCount).toBe(0);
1409+
expect(vis.reasons).toEqual([]);
1410+
});
1411+
1412+
test('buildSnapshotVisibility skips semantic analysis for raw snapshots', () => {
1413+
const nodes = [
1414+
{ ref: 'e1', index: 0, depth: 0, type: 'View', label: 'Root', hiddenContentBelow: true },
1415+
];
1416+
const vis = buildSnapshotVisibility({ nodes, backend: 'android', snapshotRaw: true });
1417+
expect(vis.partial).toBe(false);
1418+
expect(vis.visibleNodeCount).toBe(1);
1419+
expect(vis.totalNodeCount).toBe(1);
1420+
expect(vis.reasons).toEqual([]);
1421+
});
1422+
1423+
test('buildSnapshotVisibility skips semantic analysis for macos-helper backend', () => {
1424+
const nodes = [
1425+
{ ref: 'e1', index: 0, depth: 0, type: 'AXButton', label: 'Click Me' },
1426+
];
1427+
const vis = buildSnapshotVisibility({ nodes, backend: 'macos-helper' });
1428+
expect(vis.partial).toBe(false);
1429+
expect(vis.reasons).toEqual([]);
1430+
});
1431+
1432+
test('buildSnapshotVisibility detects scroll-hidden-above and scroll-hidden-below', () => {
1433+
const nodes = [
1434+
{
1435+
ref: 'e1',
1436+
index: 0,
1437+
depth: 0,
1438+
type: 'ScrollView',
1439+
label: 'Feed',
1440+
hiddenContentAbove: true,
1441+
hiddenContentBelow: true,
1442+
},
1443+
];
1444+
const vis = buildSnapshotVisibility({ nodes, backend: 'android' });
1445+
expect(vis.partial).toBe(true);
1446+
expect(vis.reasons).toContain('scroll-hidden-above');
1447+
expect(vis.reasons).toContain('scroll-hidden-below');
1448+
});
1449+
1450+
test('buildSnapshotVisibility handles nodes with no scroll hints as non-partial', () => {
1451+
const nodes = [
1452+
{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'OK', hittable: true },
1453+
{ ref: 'e2', index: 1, depth: 0, type: 'Button', label: 'Cancel', hittable: true },
1454+
];
1455+
const vis = buildSnapshotVisibility({ nodes, backend: 'xctest' });
1456+
expect(vis.partial).toBe(false);
1457+
expect(vis.visibleNodeCount).toBe(2);
1458+
expect(vis.totalNodeCount).toBe(2);
1459+
expect(vis.reasons).toEqual([]);
1460+
});

src/daemon/handlers/find.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export async function handleFindCommands(params: {
7878
backend?: SnapshotState['backend'];
7979
}> => {
8080
const now = Date.now();
81+
// Re-use a snapshot captured within the last 750 ms to avoid redundant dumps during
82+
// rapid find iterations. Skipped when Android freshness tracking is active, because
83+
// the cached tree may already be stale from a recent navigation action.
8184
if (lastNodes && now - lastSnapshotAt < 750 && !getActiveAndroidSnapshotFreshness(session)) {
8285
return { nodes: lastNodes };
8386
}

src/daemon/handlers/interaction-scroll.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ export async function handleScrollIntoViewCommand(
135135

136136
const distance = distanceFromSafeViewportBand(node.rect, viewportRect);
137137
if (distance === 0) break;
138+
// If the target didn't get closer after a scroll, count it as a stall. Two
139+
// consecutive stalls (no progress in either attempt) indicate the element is
140+
// likely unreachable — e.g. inside a non-scrollable container or already at
141+
// the scroll boundary.
138142
if (distance >= lastDistance) {
139143
stalledCount += 1;
140144
if (stalledCount >= 2) {

src/daemon/handlers/interaction-targeting.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ function findPreferredActionableDescendant(
272272
}
273273

274274
function areRectsApproximatelyEqual(left: Rect, right: Rect): boolean {
275+
// 0.5 px tolerance absorbs sub-pixel rounding differences that are common in
276+
// accessibility tree coordinates across iOS and Android DPI scales.
275277
const tolerance = 0.5;
276278
return (
277279
Math.abs(left.x - right.x) <= tolerance &&
@@ -312,6 +314,10 @@ function resolveRootViewportRect(nodes: SnapshotNode[], targetRect: Rect): Rect
312314
return pickLargestRect(containingRects.length > 0 ? containingRects : viewportRects);
313315
}
314316

317+
// An ancestor is "viewport-sized" when it covers ≥90% of the viewport area and
318+
// at least 80% of its own area overlaps. This catches full-screen containers
319+
// (navigation bars, root views) that are technically hittable but would produce
320+
// imprecise taps if used as the touch target.
315321
function isRectViewportSized(rect: Rect, viewportRect: Rect): boolean {
316322
const overlapArea = intersectionArea(rect, viewportRect);
317323
const rectArea = rect.width * rect.height;

0 commit comments

Comments
 (0)