diff --git a/package.json b/package.json index e2f110ee..cebf6ae2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "./selectors": { "import": "./dist/src/selectors.js", "types": "./dist/src/selectors.d.ts" + }, + "./finders": { + "import": "./dist/src/finders.js", + "types": "./dist/src/finders.d.ts" } }, "engines": { diff --git a/rslib.config.ts b/rslib.config.ts index 5d26d9c3..8eaa86fd 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ 'remote-config': 'src/remote-config.ts', contracts: 'src/contracts.ts', selectors: 'src/selectors.ts', + finders: 'src/finders.ts', }, tsconfigPath: 'tsconfig.lib.json', }, diff --git a/src/__tests__/finders-public.test.ts b/src/__tests__/finders-public.test.ts new file mode 100644 index 00000000..25d8d12a --- /dev/null +++ b/src/__tests__/finders-public.test.ts @@ -0,0 +1,30 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + findBestMatchesByLocator, + normalizeRole, + normalizeText, + parseFindArgs, +} from '../finders.ts'; +import type { SnapshotNode } from '../utils/snapshot.ts'; + +function makeNode(ref: string, label?: string): SnapshotNode { + return { + index: Number(ref.replace('e', '')) || 0, + ref, + type: 'XCUIElementTypeButton', + label, + }; +} + +test('public finders entrypoint re-exports pure helpers', () => { + const nodes: SnapshotNode[] = [makeNode('e1', 'Continue')]; + + const parsed = parseFindArgs(['label', 'Continue', 'click']); + const best = findBestMatchesByLocator(nodes, 'label', 'Continue', true); + + assert.equal(normalizeText(' Continue\nNow '), 'continue now'); + assert.equal(normalizeRole('XCUIElementTypeApplication.XCUIElementTypeButton'), 'button'); + assert.equal(parsed.action, 'click'); + assert.equal(best.matches.length, 0); +}); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index dd9298d4..c81dd1ea 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -1,18 +1,18 @@ import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; -import { findBestMatchesByLocator, type FindLocator } from '../../utils/finders.ts'; +import { findBestMatchesByLocator, parseFindArgs, type FindLocator } from '../../utils/finders.ts'; import { centerOfRect, type SnapshotState } from '../../utils/snapshot.ts'; -import { AppError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { extractNodeText, findNearestHittableAncestor } from '../snapshot-processing.ts'; -import { parseTimeout } from './parse-utils.ts'; import { readTextForNode } from './interaction-read.ts'; import { captureSnapshot } from './snapshot-capture.ts'; import { errorResponse } from './response.ts'; import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; +export { parseFindArgs } from '../../utils/finders.ts'; + type FindContext = { req: DaemonRequest; sessionName: string; @@ -381,60 +381,6 @@ function buildAmbiguousMatchError( ); } -type FindAction = - | { kind: 'click' } - | { kind: 'focus' } - | { kind: 'fill'; value: string } - | { kind: 'type'; value: string } - | { kind: 'get_text' } - | { kind: 'get_attrs' } - | { kind: 'exists' } - | { kind: 'wait'; timeoutMs?: number }; - -export function parseFindArgs(args: string[]): { - locator: FindLocator; - query: string; - action: FindAction['kind']; - value?: string; - timeoutMs?: number; -} { - const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id']; - let locator: FindLocator = 'any'; - let queryIndex = 0; - if (locatorTokens.includes(args[0] as FindLocator)) { - locator = args[0] as FindLocator; - queryIndex = 1; - } - const query = args[queryIndex] ?? ''; - const actionTokens = args.slice(queryIndex + 1); - if (actionTokens.length === 0) { - return { locator, query, action: 'click' }; - } - const action = actionTokens[0].toLowerCase(); - if (action === 'get') { - const sub = actionTokens[1]?.toLowerCase(); - if (sub === 'text') return { locator, query, action: 'get_text' }; - if (sub === 'attrs') return { locator, query, action: 'get_attrs' }; - throw new AppError('INVALID_ARGS', 'find get only supports text or attrs'); - } - if (action === 'wait') { - const timeoutMs = parseTimeout(actionTokens[1]); - return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined }; - } - if (action === 'exists') return { locator, query, action: 'exists' }; - if (action === 'click') return { locator, query, action: 'click' }; - if (action === 'focus') return { locator, query, action: 'focus' }; - if (action === 'fill') { - const value = actionTokens.slice(1).join(' '); - return { locator, query, action: 'fill', value }; - } - if (action === 'type') { - const value = actionTokens.slice(1).join(' '); - return { locator, query, action: 'type', value }; - } - throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`); -} - function shouldScopeFind(locator: FindLocator): boolean { return locator !== 'role'; } diff --git a/src/finders.ts b/src/finders.ts new file mode 100644 index 00000000..6eb974dd --- /dev/null +++ b/src/finders.ts @@ -0,0 +1,17 @@ +export type { FindLocator } from './utils/finders.ts'; +export { normalizeRole, normalizeText, parseFindArgs } from './utils/finders.ts'; + +import { + findBestMatchesByLocator as findBestMatchesByLocatorInternal, + type FindLocator, +} from './utils/finders.ts'; +import type { SnapshotNode } from './utils/snapshot.ts'; + +export function findBestMatchesByLocator( + nodes: SnapshotNode[], + locator: FindLocator, + query: string, + requireRect?: boolean, +) { + return findBestMatchesByLocatorInternal(nodes, locator, query, { requireRect }); +} diff --git a/src/utils/finders.ts b/src/utils/finders.ts index fbae25dc..b775b4e7 100644 --- a/src/utils/finders.ts +++ b/src/utils/finders.ts @@ -1,7 +1,18 @@ import type { SnapshotNode } from './snapshot.ts'; +import { AppError } from './errors.ts'; export type FindLocator = 'any' | 'text' | 'label' | 'value' | 'role' | 'id'; +export type FindAction = + | { kind: 'click' } + | { kind: 'focus' } + | { kind: 'fill'; value: string } + | { kind: 'type'; value: string } + | { kind: 'get_text' } + | { kind: 'get_attrs' } + | { kind: 'exists' } + | { kind: 'wait'; timeoutMs?: number }; + type FindMatchOptions = { requireRect?: boolean; }; @@ -89,10 +100,60 @@ export function normalizeText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, ' '); } -function normalizeRole(value: string): string { +export function normalizeRole(value: string): string { let normalized = value.trim(); if (!normalized) return ''; const lastSegment = normalized.split('.').pop() ?? normalized; normalized = lastSegment.replace(/XCUIElementType/gi, '').toLowerCase(); return normalized; } + +export function parseFindArgs(args: string[]): { + locator: FindLocator; + query: string; + action: FindAction['kind']; + value?: string; + timeoutMs?: number; +} { + const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id']; + let locator: FindLocator = 'any'; + let queryIndex = 0; + if (locatorTokens.includes(args[0] as FindLocator)) { + locator = args[0] as FindLocator; + queryIndex = 1; + } + const query = args[queryIndex] ?? ''; + const actionTokens = args.slice(queryIndex + 1); + if (actionTokens.length === 0) { + return { locator, query, action: 'click' }; + } + const action = actionTokens[0].toLowerCase(); + if (action === 'get') { + const sub = actionTokens[1]?.toLowerCase(); + if (sub === 'text') return { locator, query, action: 'get_text' }; + if (sub === 'attrs') return { locator, query, action: 'get_attrs' }; + throw new AppError('INVALID_ARGS', 'find get only supports text or attrs'); + } + if (action === 'wait') { + const timeoutMs = parseTimeout(actionTokens[1]); + return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined }; + } + if (action === 'exists') return { locator, query, action: 'exists' }; + if (action === 'click') return { locator, query, action: 'click' }; + if (action === 'focus') return { locator, query, action: 'focus' }; + if (action === 'fill') { + const value = actionTokens.slice(1).join(' '); + return { locator, query, action: 'fill', value }; + } + if (action === 'type') { + const value = actionTokens.slice(1).join(' '); + return { locator, query, action: 'type', value }; + } + throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`); +} + +function parseTimeout(value: string | undefined): number | null { + if (!value) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}