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
639 changes: 279 additions & 360 deletions src/cli.ts

Large diffs are not rendered by default.

9 changes: 2 additions & 7 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sendToDaemon } from './daemon-client.ts';
import { prepareMetroRuntime } from './client-metro.ts';
import { AppError } from './utils/errors.ts';
import { throwDaemonError } from './daemon-error.ts';
import {
buildFlags,
buildMeta,
Expand Down Expand Up @@ -57,12 +57,7 @@ export function createAgentDeviceClient(
meta: buildMeta(merged),
});
if (!response.ok) {
throw new AppError(response.error.code as any, response.error.message, {
...(response.error.details ?? {}),
hint: response.error.hint,
diagnosticId: response.error.diagnosticId,
logPath: response.error.logPath,
});
throwDaemonError(response.error);
}
return (response.data ?? {}) as Record<string, unknown>;
};
Expand Down
2 changes: 0 additions & 2 deletions src/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// Keep this public daemon contract shape aligned with MetroRuntimeHints in src/metro.ts
// and the internal MetroRuntimeHints in src/client-metro.ts.
export type SessionRuntimeHints = {
platform?: 'ios' | 'android';
metroHost?: string;
Expand Down
11 changes: 11 additions & 0 deletions src/daemon-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { AppError } from './utils/errors.ts';
import type { DaemonError } from './contracts.ts';

export function throwDaemonError(error: DaemonError): never {
throw new AppError(error.code as any, error.message, {
...(error.details ?? {}),
hint: error.hint,
diagnosticId: error.diagnosticId,
logPath: error.logPath,
});
}
37 changes: 14 additions & 23 deletions src/daemon/handlers/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async function handleFindWait(
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
return errorResponse('COMMAND_FAILED', 'find wait timed out');
}

async function handleFindExists(ctx: FindContext): Promise<DaemonResponse> {
Expand Down Expand Up @@ -280,7 +280,7 @@ async function handleFindFill(
): Promise<DaemonResponse> {
const { req, sessionName, sessionStore, session, invoke, command } = ctx;
if (!value) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find fill requires text' } };
return errorResponse('INVALID_ARGS', 'find fill requires text');
}
const response = await invoke({
token: req.token,
Expand All @@ -305,10 +305,7 @@ async function handleFindFocus(ctx: FindContext, match: ResolvedMatch): Promise<
const { req, sessionStore, session, device, command, logPath } = ctx;
const coords = match.node.rect ? centerOfRect(match.node.rect) : null;
if (!coords) {
return {
ok: false,
error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' },
};
return errorResponse('COMMAND_FAILED', 'matched element has no bounds');
}
const response = await dispatchCommand(
device,
Expand Down Expand Up @@ -337,14 +334,11 @@ async function handleFindType(
): Promise<DaemonResponse> {
const { req, sessionStore, session, device, command, logPath } = ctx;
if (!value) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find type requires text' } };
return errorResponse('INVALID_ARGS', 'find type requires text');
}
const coords = match.node.rect ? centerOfRect(match.node.rect) : null;
if (!coords) {
return {
ok: false,
error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' },
};
return errorResponse('COMMAND_FAILED', 'matched element has no bounds');
}
await dispatchCommand(device, 'focus', [String(coords.x), String(coords.y)], req.flags?.out, {
...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath),
Expand Down Expand Up @@ -375,19 +369,16 @@ function buildAmbiguousMatchError(
extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
return `@${candidate.ref}${label ? `(${label})` : ''}`;
});
return {
ok: false,
error: {
code: 'AMBIGUOUS_MATCH',
message: `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
details: {
locator,
query,
matches: matches.length,
candidates,
},
return errorResponse(
'AMBIGUOUS_MATCH',
`find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
{
locator,
query,
matches: matches.length,
candidates,
},
};
);
}

type FindAction =
Expand Down
31 changes: 31 additions & 0 deletions src/daemon/handlers/handler-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,34 @@ export function recordSessionAction(
result: result ?? {},
});
}

/**
* Flag keys inherited from a parent request (batch/replay) into child step flags.
* Shared between batch and replay so the inheritance rules stay in sync.
*/
export const INHERITED_PARENT_FLAG_KEYS: ReadonlyArray<keyof CommandFlags> = [
'platform',
'target',
'device',
'udid',
'serial',
'verbose',
'out',
];

/**
* Merge parent flag values into child flags for keys that are undefined in the child.
*/
export function mergeParentFlags(
parentFlags: CommandFlags | undefined,
childFlags: CommandFlags,
): CommandFlags {
const parentRecord = (parentFlags ?? {}) as Record<string, unknown>;
const childRecord = childFlags as Record<string, unknown>;
for (const key of INHERITED_PARENT_FLAG_KEYS) {
if (childRecord[key] === undefined && parentRecord[key] !== undefined) {
childRecord[key] = parentRecord[key];
}
}
return childFlags;
}
12 changes: 5 additions & 7 deletions src/daemon/handlers/install-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import { resolveInstallFromSourceResultTarget } from '../../client-shared.ts';
import { AppError, normalizeError } from '../../utils/errors.ts';
import { withSuccessText } from '../../utils/success-text.ts';
import { errorResponse } from './response.ts';

function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | undefined {
return platform === 'ios' || platform === 'android' ? platform : undefined;
Expand Down Expand Up @@ -73,13 +74,10 @@ export async function handleInstallFromSourceCommand(params: {
flags: req.flags,
});
if (!isCommandSupportedOnDevice('install', device)) {
return {
ok: false,
error: {
code: 'UNSUPPORTED_OPERATION',
message: 'install_from_source is not supported on this device',
},
};
return errorResponse(
'UNSUPPORTED_OPERATION',
'install_from_source is not supported on this device',
);
}

const requestSignal = getRequestSignal(req.meta?.requestId);
Expand Down
52 changes: 13 additions & 39 deletions src/daemon/handlers/interaction-fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-f
import { resolveRefTargetWithRectRefresh, type ResolveRefTarget } from './interaction-targeting.ts';
import { unsupportedMacOsDesktopSurfaceInteraction } from './interaction-touch-policy.ts';
import type { RefSnapshotFlagGuardResponse } from './interaction-flags.ts';
import { errorResponse } from './response.ts';

export async function handleFillCommand(params: {
req: DaemonRequest;
Expand Down Expand Up @@ -49,17 +50,11 @@ export async function handleFillCommand(params: {
}
}
if (session && !isCommandSupportedOnDevice('fill', session.device)) {
return {
ok: false,
error: { code: 'UNSUPPORTED_OPERATION', message: 'fill is not supported on this device' },
};
return errorResponse('UNSUPPORTED_OPERATION', 'fill is not supported on this device');
}
if (req.positionals?.[0]?.startsWith('@')) {
if (!session) {
return {
ok: false,
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
};
return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
}
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
Expand All @@ -70,10 +65,7 @@ export async function handleFillCommand(params: {
? req.positionals.slice(2).join(' ')
: req.positionals.slice(1).join(' ');
if (!text) {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' },
};
return errorResponse('INVALID_ARGS', 'fill requires text after ref');
}

const resolvedRefFillTarget = await resolveRefTargetWithRectRefresh({
Expand All @@ -91,7 +83,7 @@ export async function handleFillCommand(params: {
captureSnapshotForSession,
resolveRefTarget,
});
if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget.response;
if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget;

const { ref, node, snapshotNodes, point } = resolvedRefFillTarget.target;
const nodeType = node.type ?? '';
Expand Down Expand Up @@ -140,28 +132,19 @@ export async function handleFillCommand(params: {
}

if (!session) {
return {
ok: false,
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
};
return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
}

const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], {
preferTrailingValue: true,
});
if (selectorArgs) {
if (selectorArgs.rest.length === 0) {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
};
return errorResponse('INVALID_ARGS', 'fill requires text after selector');
}
const text = selectorArgs.rest.join(' ').trim();
if (!text) {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
};
return errorResponse('INVALID_ARGS', 'fill requires text after selector');
}

const chain = parseSelectorChain(selectorArgs.selectorExpression);
Expand All @@ -184,13 +167,10 @@ export async function handleFillCommand(params: {
{ command: req.command },
);
if (!resolved || !resolved.node.rect) {
return {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }),
},
};
return errorResponse(
'COMMAND_FAILED',
formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }),
);
}

const node = resolved.node;
Expand Down Expand Up @@ -235,11 +215,5 @@ export async function handleFillCommand(params: {
});
}

return {
ok: false,
error: {
code: 'INVALID_ARGS',
message: 'fill requires x y text, @ref text, or selector text',
},
};
return errorResponse('INVALID_ARGS', 'fill requires x y text, @ref text, or selector text');
}
12 changes: 5 additions & 7 deletions src/daemon/handlers/interaction-flags.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CommandFlags } from '../../core/dispatch.ts';
import type { DaemonResponse } from '../types.ts';
import { errorResponse } from './response.ts';

const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
['snapshotDepth', '--depth'],
Expand All @@ -13,13 +14,10 @@ export function refSnapshotFlagGuardResponse(
): DaemonResponse | null {
const unsupported = unsupportedRefSnapshotFlags(flags);
if (unsupported.length === 0) return null;
return {
ok: false,
error: {
code: 'INVALID_ARGS',
message: `${command} @ref does not support ${unsupported.join(', ')}.`,
},
};
return errorResponse(
'INVALID_ARGS',
`${command} @ref does not support ${unsupported.join(', ')}.`,
);
}

export type RefSnapshotFlagGuardResponse = typeof refSnapshotFlagGuardResponse;
Expand Down
27 changes: 7 additions & 20 deletions src/daemon/handlers/interaction-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,18 @@ import { refSnapshotFlagGuardResponse } from './interaction-flags.ts';
import { readTextForNode } from './interaction-read.ts';
import { resolveRefTarget } from './interaction-targeting.ts';
import { resolveSelectorTarget } from './interaction-selector.ts';
import { errorResponse } from './response.ts';

export async function handleGetCommand(params: InteractionHandlerParams): Promise<DaemonResponse> {
const { req, sessionName, sessionStore, contextFromFlags } = params;
const sub = req.positionals?.[0];
if (sub !== 'text' && sub !== 'attrs') {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' },
};
return errorResponse('INVALID_ARGS', 'get only supports text or attrs');
}
const session = sessionStore.get(sessionName);
if (!session) {
return {
ok: false,
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
};
}
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!isCommandSupportedOnDevice('get', session.device)) {
return {
ok: false,
error: { code: 'UNSUPPORTED_OPERATION', message: 'get is not supported on this device' },
};
return errorResponse('UNSUPPORTED_OPERATION', 'get is not supported on this device');
}
const refInput = req.positionals?.[1] ?? '';
if (refInput.startsWith('@')) {
Expand All @@ -43,7 +33,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
invalidRefMessage: 'get text requires a ref like @e2',
notFoundMessage: `Ref ${refInput} not found`,
});
if (!resolvedRefTarget.ok) return resolvedRefTarget.response;
if (!resolvedRefTarget.ok) return resolvedRefTarget;
const { ref, node } = resolvedRefTarget.target;
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
action: 'get',
Expand Down Expand Up @@ -77,10 +67,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis

const selectorExpression = req.positionals.slice(1).join(' ').trim();
if (!selectorExpression) {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: 'get requires @ref or selector expression' },
};
return errorResponse('INVALID_ARGS', 'get requires @ref or selector expression');
}
const resolvedSelectorTarget = await resolveSelectorTarget({
command: req.command,
Expand All @@ -94,7 +81,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
requireUnique: true,
disambiguateAmbiguous: sub === 'text',
});
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response;
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget;
const { resolved } = resolvedSelectorTarget;
const node = resolved.node;
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
Expand Down
Loading
Loading