mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
0a8ea3a57b
* refactor: consolidate architecture ownership and client results Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: keep selector parse chunk grouping current Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: update moved architecture breadcrumbs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: enforce moved selector architecture Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: keep selector guarantee ownership current Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: update selector ownership references Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
import { AppError } from '../kernel/errors.ts';
|
|
import {
|
|
detectUnknownSelectorKeyToken,
|
|
isRoleHintWord,
|
|
isSelectorToken,
|
|
SELECTOR_KEY_NAMES,
|
|
splitSelectorFromArgs,
|
|
} from '../selectors/parse.ts';
|
|
|
|
type PositionalInteractionTarget =
|
|
| { x: number; y: number }
|
|
| { ref: string; label?: string }
|
|
| { selector: string };
|
|
|
|
export type DecodedFillTarget =
|
|
| { kind: 'ref'; target: { ref: string; label?: string }; text: string }
|
|
| { kind: 'selector'; target: { selector: string }; text: string }
|
|
| { kind: 'point'; target: { x: number; y: number }; text: string };
|
|
|
|
const BARE_SNAPSHOT_REF_PATTERN = /^e\d+$/;
|
|
|
|
export function readInteractionTargetFromPositionals(
|
|
positionals: string[],
|
|
): PositionalInteractionTarget {
|
|
if (positionals[0]?.startsWith('@')) {
|
|
const label = optionalTrimmedText(positionals.slice(1));
|
|
return { ref: positionals[0], ...(label === undefined ? {} : { label }) };
|
|
}
|
|
if (BARE_SNAPSHOT_REF_PATTERN.test(positionals[0] ?? '')) {
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
`Did you mean "@${positionals[0]}"? Snapshot refs need the @ prefix.`,
|
|
);
|
|
}
|
|
const selectorArgs = splitSelectorFromArgs(positionals);
|
|
if (selectorArgs) {
|
|
if (selectorArgs.rest.length > 0) {
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
formatSelectorTrailingArgsFailure(positionals, selectorArgs),
|
|
);
|
|
}
|
|
return { selector: selectorArgs.selectorExpression };
|
|
}
|
|
return readPointTarget(positionals);
|
|
}
|
|
|
|
export function readFillTargetFromPositionals(positionals: string[]): DecodedFillTarget {
|
|
const firstPositional = positionals[0];
|
|
if (firstPositional?.startsWith('@')) {
|
|
const text =
|
|
positionals.length >= 3 ? positionals.slice(2).join(' ') : positionals.slice(1).join(' ');
|
|
return {
|
|
kind: 'ref',
|
|
target: {
|
|
ref: firstPositional,
|
|
label: positionals.length >= 3 ? optionalTrimmedText(positionals.slice(1, 2)) : undefined,
|
|
},
|
|
text,
|
|
};
|
|
}
|
|
if (BARE_SNAPSHOT_REF_PATTERN.test(firstPositional ?? '')) {
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
`Did you mean "@${firstPositional}"? Snapshot refs need the @ prefix.`,
|
|
);
|
|
}
|
|
const selectorArgs = splitSelectorFromArgs(positionals, { preferTrailingValue: true });
|
|
if (selectorArgs) {
|
|
return {
|
|
kind: 'selector',
|
|
target: { selector: selectorArgs.selectorExpression },
|
|
text: selectorArgs.rest.join(' '),
|
|
};
|
|
}
|
|
return {
|
|
kind: 'point',
|
|
target: readPointTarget(positionals.slice(0, 2)),
|
|
text: positionals.slice(2).join(' '),
|
|
};
|
|
}
|
|
|
|
function readPointTarget(positionals: string[]): { x: number; y: number } {
|
|
const firstPositional = positionals[0] ?? '';
|
|
const unknownKey = detectUnknownSelectorKeyToken(firstPositional);
|
|
if (unknownKey) {
|
|
// An unquoted multi-word value arrives split across positionals (button=Push Article), so
|
|
// fold the trailing tokens back into the suggested value like mergeRestIntoSelectorValue.
|
|
// A fully quoted value (button="Push Article") is complete; trailing tokens are not part of it.
|
|
const value = hasCompleteQuotedValue(firstPositional)
|
|
? unknownKey.value
|
|
: [unknownKey.value, ...positionals.slice(1)].join(' ').trim();
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
formatUnknownSelectorKeyFailure({ key: unknownKey.key, value }),
|
|
);
|
|
}
|
|
const x = Number(positionals[0]);
|
|
const y = Number(positionals[1]);
|
|
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
|
|
throw new AppError('INVALID_ARGS', formatTargetParseFailure(positionals));
|
|
}
|
|
|
|
function hasCompleteQuotedValue(token: string): boolean {
|
|
const valueRaw = token.slice(token.indexOf('=') + 1).trim();
|
|
if (valueRaw.length < 2) return false;
|
|
const first = valueRaw[0];
|
|
return (first === '"' || first === "'") && valueRaw.endsWith(first);
|
|
}
|
|
|
|
function formatUnknownSelectorKeyFailure(unknownKey: { key: string; value: string }): string {
|
|
const { key, value } = unknownKey;
|
|
const supported = SELECTOR_KEY_NAMES.join(', ');
|
|
const hint = isRoleHintWord(key)
|
|
? `Did you mean role=${key} label=${quoteSelectorValue(value)}?`
|
|
: `Did you mean label=${quoteSelectorValue(value)}?`;
|
|
return `Unknown selector key "${key}". Supported: ${supported}. ${hint}`;
|
|
}
|
|
|
|
function formatTargetParseFailure(positionals: string[]): string {
|
|
const raw = positionals.filter((part) => part !== undefined).join(' ');
|
|
if (!raw.trim()) {
|
|
return 'Missing target. Pass an @ref from snapshot, a selector like text="Sign in", or "x y" coordinates.';
|
|
}
|
|
const base = `Target "${raw}" is not a @ref, selector, or "x y" point.`;
|
|
if (positionals.some((part) => isSelectorToken(part ?? ''))) {
|
|
return `${base} Selector values with spaces need quotes, e.g. text="Sign in".`;
|
|
}
|
|
return `${base} To match by visible text, use text=${quoteSelectorValue(raw)} (or label=/id=/role=), or pass an @ref from snapshot.`;
|
|
}
|
|
|
|
function formatSelectorTrailingArgsFailure(
|
|
positionals: string[],
|
|
selectorArgs: { selectorExpression: string; rest: string[] },
|
|
): string {
|
|
const base = `Selector ${selectorArgs.selectorExpression} is followed by unexpected extra arguments: "${selectorArgs.rest.join(' ')}".`;
|
|
const suggestion = mergeRestIntoSelectorValue(positionals, selectorArgs) ?? 'text="Sign in"';
|
|
return `${base} Selector values with spaces need quotes, e.g. ${suggestion}.`;
|
|
}
|
|
|
|
function mergeRestIntoSelectorValue(
|
|
positionals: string[],
|
|
selectorArgs: { selectorExpression: string; rest: string[] },
|
|
): string | undefined {
|
|
const boundary = positionals.length - selectorArgs.rest.length;
|
|
const lastSelectorToken = positionals[boundary - 1];
|
|
if (lastSelectorToken === undefined) return undefined;
|
|
const equalsIdx = lastSelectorToken.indexOf('=');
|
|
if (equalsIdx <= 0) return undefined;
|
|
const mergedValue = [lastSelectorToken.slice(equalsIdx + 1), ...selectorArgs.rest].join(' ');
|
|
const mergedToken = `${lastSelectorToken.slice(0, equalsIdx + 1)}${quoteSelectorValue(mergedValue)}`;
|
|
return [...positionals.slice(0, boundary - 1), mergedToken].join(' ');
|
|
}
|
|
|
|
function quoteSelectorValue(value: string): string {
|
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
}
|
|
|
|
function optionalTrimmedText(parts: string[]): string | undefined {
|
|
const text = parts.join(' ').trim();
|
|
return text ? text : undefined;
|
|
}
|