Skip to content

Commit 966fcb2

Browse files
authored
feat: export finder helpers (#391)
1 parent 84098be commit 966fcb2

6 files changed

Lines changed: 117 additions & 58 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
"./selectors": {
2828
"import": "./dist/src/selectors.js",
2929
"types": "./dist/src/selectors.d.ts"
30+
},
31+
"./finders": {
32+
"import": "./dist/src/finders.js",
33+
"types": "./dist/src/finders.d.ts"
3034
}
3135
},
3236
"engines": {

rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineConfig({
2121
'remote-config': 'src/remote-config.ts',
2222
contracts: 'src/contracts.ts',
2323
selectors: 'src/selectors.ts',
24+
finders: 'src/finders.ts',
2425
},
2526
tsconfigPath: 'tsconfig.lib.json',
2627
},
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import {
4+
findBestMatchesByLocator,
5+
normalizeRole,
6+
normalizeText,
7+
parseFindArgs,
8+
} from '../finders.ts';
9+
import type { SnapshotNode } from '../utils/snapshot.ts';
10+
11+
function makeNode(ref: string, label?: string): SnapshotNode {
12+
return {
13+
index: Number(ref.replace('e', '')) || 0,
14+
ref,
15+
type: 'XCUIElementTypeButton',
16+
label,
17+
};
18+
}
19+
20+
test('public finders entrypoint re-exports pure helpers', () => {
21+
const nodes: SnapshotNode[] = [makeNode('e1', 'Continue')];
22+
23+
const parsed = parseFindArgs(['label', 'Continue', 'click']);
24+
const best = findBestMatchesByLocator(nodes, 'label', 'Continue', true);
25+
26+
assert.equal(normalizeText(' Continue\nNow '), 'continue now');
27+
assert.equal(normalizeRole('XCUIElementTypeApplication.XCUIElementTypeButton'), 'button');
28+
assert.equal(parsed.action, 'click');
29+
assert.equal(best.matches.length, 0);
30+
});

src/daemon/handlers/find.ts

Lines changed: 3 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
2-
import { findBestMatchesByLocator, type FindLocator } from '../../utils/finders.ts';
2+
import { findBestMatchesByLocator, parseFindArgs, type FindLocator } from '../../utils/finders.ts';
33
import { centerOfRect, type SnapshotState } from '../../utils/snapshot.ts';
4-
import { AppError } from '../../utils/errors.ts';
54
import type { DaemonRequest, DaemonResponse } from '../types.ts';
65
import { SessionStore } from '../session-store.ts';
76
import { contextFromFlags } from '../context.ts';
87
import { ensureDeviceReady } from '../device-ready.ts';
98
import { extractNodeText, findNearestHittableAncestor } from '../snapshot-processing.ts';
10-
import { parseTimeout } from './parse-utils.ts';
119
import { readTextForNode } from './interaction-read.ts';
1210
import { captureSnapshot } from './snapshot-capture.ts';
1311
import { errorResponse } from './response.ts';
1412
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
1513

14+
export { parseFindArgs } from '../../utils/finders.ts';
15+
1616
type FindContext = {
1717
req: DaemonRequest;
1818
sessionName: string;
@@ -381,60 +381,6 @@ function buildAmbiguousMatchError(
381381
);
382382
}
383383

384-
type FindAction =
385-
| { kind: 'click' }
386-
| { kind: 'focus' }
387-
| { kind: 'fill'; value: string }
388-
| { kind: 'type'; value: string }
389-
| { kind: 'get_text' }
390-
| { kind: 'get_attrs' }
391-
| { kind: 'exists' }
392-
| { kind: 'wait'; timeoutMs?: number };
393-
394-
export function parseFindArgs(args: string[]): {
395-
locator: FindLocator;
396-
query: string;
397-
action: FindAction['kind'];
398-
value?: string;
399-
timeoutMs?: number;
400-
} {
401-
const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id'];
402-
let locator: FindLocator = 'any';
403-
let queryIndex = 0;
404-
if (locatorTokens.includes(args[0] as FindLocator)) {
405-
locator = args[0] as FindLocator;
406-
queryIndex = 1;
407-
}
408-
const query = args[queryIndex] ?? '';
409-
const actionTokens = args.slice(queryIndex + 1);
410-
if (actionTokens.length === 0) {
411-
return { locator, query, action: 'click' };
412-
}
413-
const action = actionTokens[0].toLowerCase();
414-
if (action === 'get') {
415-
const sub = actionTokens[1]?.toLowerCase();
416-
if (sub === 'text') return { locator, query, action: 'get_text' };
417-
if (sub === 'attrs') return { locator, query, action: 'get_attrs' };
418-
throw new AppError('INVALID_ARGS', 'find get only supports text or attrs');
419-
}
420-
if (action === 'wait') {
421-
const timeoutMs = parseTimeout(actionTokens[1]);
422-
return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined };
423-
}
424-
if (action === 'exists') return { locator, query, action: 'exists' };
425-
if (action === 'click') return { locator, query, action: 'click' };
426-
if (action === 'focus') return { locator, query, action: 'focus' };
427-
if (action === 'fill') {
428-
const value = actionTokens.slice(1).join(' ');
429-
return { locator, query, action: 'fill', value };
430-
}
431-
if (action === 'type') {
432-
const value = actionTokens.slice(1).join(' ');
433-
return { locator, query, action: 'type', value };
434-
}
435-
throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`);
436-
}
437-
438384
function shouldScopeFind(locator: FindLocator): boolean {
439385
return locator !== 'role';
440386
}

src/finders.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type { FindLocator } from './utils/finders.ts';
2+
export { normalizeRole, normalizeText, parseFindArgs } from './utils/finders.ts';
3+
4+
import {
5+
findBestMatchesByLocator as findBestMatchesByLocatorInternal,
6+
type FindLocator,
7+
} from './utils/finders.ts';
8+
import type { SnapshotNode } from './utils/snapshot.ts';
9+
10+
export function findBestMatchesByLocator(
11+
nodes: SnapshotNode[],
12+
locator: FindLocator,
13+
query: string,
14+
requireRect?: boolean,
15+
) {
16+
return findBestMatchesByLocatorInternal(nodes, locator, query, { requireRect });
17+
}

src/utils/finders.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import type { SnapshotNode } from './snapshot.ts';
2+
import { AppError } from './errors.ts';
23

34
export type FindLocator = 'any' | 'text' | 'label' | 'value' | 'role' | 'id';
45

6+
export type FindAction =
7+
| { kind: 'click' }
8+
| { kind: 'focus' }
9+
| { kind: 'fill'; value: string }
10+
| { kind: 'type'; value: string }
11+
| { kind: 'get_text' }
12+
| { kind: 'get_attrs' }
13+
| { kind: 'exists' }
14+
| { kind: 'wait'; timeoutMs?: number };
15+
516
type FindMatchOptions = {
617
requireRect?: boolean;
718
};
@@ -89,10 +100,60 @@ export function normalizeText(value: string): string {
89100
return value.trim().toLowerCase().replace(/\s+/g, ' ');
90101
}
91102

92-
function normalizeRole(value: string): string {
103+
export function normalizeRole(value: string): string {
93104
let normalized = value.trim();
94105
if (!normalized) return '';
95106
const lastSegment = normalized.split('.').pop() ?? normalized;
96107
normalized = lastSegment.replace(/XCUIElementType/gi, '').toLowerCase();
97108
return normalized;
98109
}
110+
111+
export function parseFindArgs(args: string[]): {
112+
locator: FindLocator;
113+
query: string;
114+
action: FindAction['kind'];
115+
value?: string;
116+
timeoutMs?: number;
117+
} {
118+
const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id'];
119+
let locator: FindLocator = 'any';
120+
let queryIndex = 0;
121+
if (locatorTokens.includes(args[0] as FindLocator)) {
122+
locator = args[0] as FindLocator;
123+
queryIndex = 1;
124+
}
125+
const query = args[queryIndex] ?? '';
126+
const actionTokens = args.slice(queryIndex + 1);
127+
if (actionTokens.length === 0) {
128+
return { locator, query, action: 'click' };
129+
}
130+
const action = actionTokens[0].toLowerCase();
131+
if (action === 'get') {
132+
const sub = actionTokens[1]?.toLowerCase();
133+
if (sub === 'text') return { locator, query, action: 'get_text' };
134+
if (sub === 'attrs') return { locator, query, action: 'get_attrs' };
135+
throw new AppError('INVALID_ARGS', 'find get only supports text or attrs');
136+
}
137+
if (action === 'wait') {
138+
const timeoutMs = parseTimeout(actionTokens[1]);
139+
return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined };
140+
}
141+
if (action === 'exists') return { locator, query, action: 'exists' };
142+
if (action === 'click') return { locator, query, action: 'click' };
143+
if (action === 'focus') return { locator, query, action: 'focus' };
144+
if (action === 'fill') {
145+
const value = actionTokens.slice(1).join(' ');
146+
return { locator, query, action: 'fill', value };
147+
}
148+
if (action === 'type') {
149+
const value = actionTokens.slice(1).join(' ');
150+
return { locator, query, action: 'type', value };
151+
}
152+
throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`);
153+
}
154+
155+
function parseTimeout(value: string | undefined): number | null {
156+
if (!value) return null;
157+
const parsed = Number(value);
158+
return Number.isFinite(parsed) ? parsed : null;
159+
}

0 commit comments

Comments
 (0)