Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/finders-public.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
60 changes: 3 additions & 57 deletions src/daemon/handlers/find.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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';
}
17 changes: 17 additions & 0 deletions src/finders.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
63 changes: 62 additions & 1 deletion src/utils/finders.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down Expand Up @@ -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;
}
Loading