Skip to content

Commit 64dd8d1

Browse files
committed
perf(ios): skip backend re-read in get text for non-editable elements
readTextForNode dispatched a coordinate 'read' to the iOS XCUITest runner for every get text, where readTextAt() enumerates the full element tree (allElementsBoundByIndex) — ~20x slower than the snapshot already captured to resolve the node. That re-read only recovers fuller text for editable/expandable inputs (textField/searchField/textView/…); for all other element types the freshly-captured snapshot node text is authoritative. Return the snapshot node text directly for non-editable nodes with non-empty readable text, skipping the round-trip. Measured on iPhone 17 sim: get text on a labeled control drops from ~25s to ~0.3s steady-state. Editable inputs keep the backend re-read (live value can exceed the snapshot).
1 parent 0a6cc72 commit 64dd8d1

3 files changed

Lines changed: 74 additions & 1 deletion

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { SnapshotNode } from '../../../utils/snapshot.ts';
3+
4+
vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
5+
const actual = await importOriginal<typeof import('../../../core/dispatch.ts')>();
6+
return {
7+
...actual,
8+
dispatchCommand: vi.fn(async () => ({ text: 'backend-text' })),
9+
};
10+
});
11+
12+
import { dispatchCommand } from '../../../core/dispatch.ts';
13+
import { readTextForNode } from '../interaction-read.ts';
14+
15+
const mockDispatch = vi.mocked(dispatchCommand);
16+
17+
function node(overrides: Partial<SnapshotNode>): SnapshotNode {
18+
return {
19+
ref: 'e1',
20+
index: 0,
21+
rect: { x: 0, y: 0, width: 100, height: 40 },
22+
...overrides,
23+
} as SnapshotNode;
24+
}
25+
26+
const baseParams = {
27+
device: { platform: 'ios' } as never,
28+
flags: undefined,
29+
contextFromFlags: () => ({}) as never,
30+
};
31+
32+
describe('readTextForNode', () => {
33+
beforeEach(() => mockDispatch.mockClear());
34+
35+
it('returns snapshot text without a backend read for non-editable nodes', async () => {
36+
const text = await readTextForNode({ ...baseParams, node: node({ type: 'button', label: 'General' }) });
37+
expect(text).toBe('General');
38+
expect(mockDispatch).not.toHaveBeenCalled();
39+
});
40+
41+
it('still re-reads via the backend for editable text inputs (live value may exceed snapshot)', async () => {
42+
const text = await readTextForNode({ ...baseParams, node: node({ type: 'textfield', value: 'snap' }) });
43+
expect(mockDispatch).toHaveBeenCalledOnce();
44+
expect(text).toBe('backend-text');
45+
});
46+
47+
it('re-reads when the snapshot node has no readable text', async () => {
48+
await readTextForNode({ ...baseParams, node: node({ type: 'other' }) });
49+
expect(mockDispatch).toHaveBeenCalledOnce();
50+
});
51+
52+
it('returns snapshot text without a backend read when the node has no resolvable center', async () => {
53+
const text = await readTextForNode({ ...baseParams, node: node({ type: 'button', label: 'General', rect: undefined }) });
54+
expect(text).toBe('General');
55+
expect(mockDispatch).not.toHaveBeenCalled();
56+
});
57+
});

src/daemon/handlers/interaction-read.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts';
33
import { extractNodeReadText } from '../snapshot-processing.ts';
44
import type { SessionState } from '../types.ts';
55
import type { SnapshotNode } from '../../utils/snapshot.ts';
6+
import { prefersValueForReadableText } from '../../utils/text-surface.ts';
67
import type { ContextFromFlags } from './interaction-common.ts';
78
import { resolveRectCenter } from './interaction-targeting.ts';
89

@@ -22,6 +23,16 @@ export async function readTextForNode(params: {
2223
return fallbackText;
2324
}
2425

26+
// The backend `read` re-resolves the element at a point, which on iOS XCUITest enumerates
27+
// the full element tree (allElementsBoundByIndex) and is ~20x slower than the snapshot we
28+
// already captured to resolve this node. That re-read only recovers fuller text for
29+
// editable/expandable inputs (textField/searchField/textView/…), where the live value can
30+
// exceed the snapshot. For every other element type the snapshot node text is authoritative,
31+
// so return it directly and skip the expensive round-trip.
32+
if (fallbackText && !prefersValueForReadableText(node.type ?? '')) {
33+
return fallbackText;
34+
}
35+
2536
try {
2637
const rawData = await dispatchCommand(
2738
device,

src/utils/text-surface.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ export function normalizeType(type: string): string {
8585
return normalized;
8686
}
8787

88-
function prefersValueForReadableText(type: string): boolean {
88+
/**
89+
* Editable / expandable text-bearing element types whose live on-screen value can exceed
90+
* the captured snapshot text. For these the readable text prefers `value`, and a backend
91+
* (e.g. iOS XCUITest) re-read at the element can recover fuller text than the snapshot node.
92+
*/
93+
export function prefersValueForReadableText(type: string): boolean {
8994
const normalized = normalizeType(type);
9095
return (
9196
normalized.includes('textfield') ||

0 commit comments

Comments
 (0)