Skip to content

Commit 4814eb7

Browse files
committed
refactor: reduce duplication and simplify codebase
Eliminate copy-pasted functions, inline error boilerplate, and repeated patterns across daemon handlers and platform modules. Key changes: - Deduplicate isEnvTruthy, displayNodeLabel, roundPercent into single sources - Extract throwDaemonError helper for client/CLI daemon response errors - Add sessionNotFoundResponse/unsupportedOperationResponse helpers and adopt errorResponse() across ~30 handler files (-565 lines) - Unify BATCH_PARENT_FLAG_KEYS/REPLAY_PARENT_FLAG_KEYS into shared mergeParentFlags helper in handler-utils - Extract createLinuxToolResolver for screenshot/clipboard tool detection - Remove stale type-sync comments from contracts.ts and metro.ts
1 parent d4ee32f commit 4814eb7

51 files changed

Lines changed: 439 additions & 938 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/cli.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { readVersion } from './utils/version.ts';
1010
import { readCommandMessage } from './utils/success-text.ts';
1111
import { pathToFileURL } from 'node:url';
1212
import { sendToDaemon } from './daemon-client.ts';
13+
import { throwDaemonError } from './daemon-error.ts';
1314
import fs from 'node:fs';
1415
import type { BatchStep } from './core/dispatch.ts';
1516
import { parseBatchStepsJson } from './core/batch.ts';
@@ -209,12 +210,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
209210
flags: batchFlags,
210211
});
211212
if (!response.ok) {
212-
throw new AppError(response.error.code as any, response.error.message, {
213-
...(response.error.details ?? {}),
214-
hint: response.error.hint,
215-
diagnosticId: response.error.diagnosticId,
216-
logPath: response.error.logPath,
217-
});
213+
throwDaemonError(response.error);
218214
}
219215
if (flags.json) {
220216
printJson({ success: true, data: response.data ?? {} });

src/client.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { sendToDaemon } from './daemon-client.ts';
22
import { prepareMetroRuntime } from './client-metro.ts';
3-
import { AppError } from './utils/errors.ts';
3+
import { throwDaemonError } from './daemon-error.ts';
44
import {
55
buildFlags,
66
buildMeta,
@@ -57,12 +57,7 @@ export function createAgentDeviceClient(
5757
meta: buildMeta(merged),
5858
});
5959
if (!response.ok) {
60-
throw new AppError(response.error.code as any, response.error.message, {
61-
...(response.error.details ?? {}),
62-
hint: response.error.hint,
63-
diagnosticId: response.error.diagnosticId,
64-
logPath: response.error.logPath,
65-
});
60+
throwDaemonError(response.error);
6661
}
6762
return (response.data ?? {}) as Record<string, unknown>;
6863
};

src/contracts.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// Keep this public daemon contract shape aligned with MetroRuntimeHints in src/metro.ts
2-
// and the internal MetroRuntimeHints in src/client-metro.ts.
31
export type SessionRuntimeHints = {
42
platform?: 'ios' | 'android';
53
metroHost?: string;

src/daemon-error.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { AppError } from './utils/errors.ts';
2+
import type { DaemonError } from './contracts.ts';
3+
4+
export function throwDaemonError(error: DaemonError): never {
5+
throw new AppError(error.code as any, error.message, {
6+
...(error.details ?? {}),
7+
hint: error.hint,
8+
diagnosticId: error.diagnosticId,
9+
logPath: error.logPath,
10+
});
11+
}

src/daemon/handlers/find.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { extractNodeText, findNearestHittableAncestor } from '../snapshot-proces
1010
import { parseTimeout } from './parse-utils.ts';
1111
import { readTextForNode } from './interaction-read.ts';
1212
import { captureSnapshot } from './snapshot-capture.ts';
13-
import { errorResponse } from './response.ts';
13+
import { errorResponse, sessionNotFoundResponse } from './response.ts';
1414
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
1515

1616
type FindContext = {
@@ -60,7 +60,7 @@ export async function handleFindCommands(params: {
6060
const isReadOnly =
6161
action === 'exists' || action === 'wait' || action === 'get_text' || action === 'get_attrs';
6262
if (!session && !isReadOnly) {
63-
return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
63+
return sessionNotFoundResponse();
6464
}
6565
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
6666
if (!session) {
@@ -194,7 +194,7 @@ async function handleFindWait(
194194
}
195195
await new Promise((resolve) => setTimeout(resolve, 300));
196196
}
197-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
197+
return errorResponse('COMMAND_FAILED', 'find wait timed out');
198198
}
199199

200200
async function handleFindExists(ctx: FindContext): Promise<DaemonResponse> {
@@ -280,7 +280,7 @@ async function handleFindFill(
280280
): Promise<DaemonResponse> {
281281
const { req, sessionName, sessionStore, session, invoke, command } = ctx;
282282
if (!value) {
283-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find fill requires text' } };
283+
return errorResponse('INVALID_ARGS', 'find fill requires text');
284284
}
285285
const response = await invoke({
286286
token: req.token,
@@ -305,10 +305,7 @@ async function handleFindFocus(ctx: FindContext, match: ResolvedMatch): Promise<
305305
const { req, sessionStore, session, device, command, logPath } = ctx;
306306
const coords = match.node.rect ? centerOfRect(match.node.rect) : null;
307307
if (!coords) {
308-
return {
309-
ok: false,
310-
error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' },
311-
};
308+
return errorResponse('COMMAND_FAILED', 'matched element has no bounds');
312309
}
313310
const response = await dispatchCommand(
314311
device,
@@ -337,14 +334,11 @@ async function handleFindType(
337334
): Promise<DaemonResponse> {
338335
const { req, sessionStore, session, device, command, logPath } = ctx;
339336
if (!value) {
340-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find type requires text' } };
337+
return errorResponse('INVALID_ARGS', 'find type requires text');
341338
}
342339
const coords = match.node.rect ? centerOfRect(match.node.rect) : null;
343340
if (!coords) {
344-
return {
345-
ok: false,
346-
error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' },
347-
};
341+
return errorResponse('COMMAND_FAILED', 'matched element has no bounds');
348342
}
349343
await dispatchCommand(device, 'focus', [String(coords.x), String(coords.y)], req.flags?.out, {
350344
...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath),
@@ -375,19 +369,16 @@ function buildAmbiguousMatchError(
375369
extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
376370
return `@${candidate.ref}${label ? `(${label})` : ''}`;
377371
});
378-
return {
379-
ok: false,
380-
error: {
381-
code: 'AMBIGUOUS_MATCH',
382-
message: `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
383-
details: {
384-
locator,
385-
query,
386-
matches: matches.length,
387-
candidates,
388-
},
372+
return errorResponse(
373+
'AMBIGUOUS_MATCH',
374+
`find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
375+
{
376+
locator,
377+
query,
378+
matches: matches.length,
379+
candidates,
389380
},
390-
};
381+
);
391382
}
392383

393384
type FindAction =

src/daemon/handlers/handler-utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,34 @@ export function recordSessionAction(
2020
result: result ?? {},
2121
});
2222
}
23+
24+
/**
25+
* Flag keys inherited from a parent request (batch/replay) into child step flags.
26+
* Shared between batch and replay so the inheritance rules stay in sync.
27+
*/
28+
export const INHERITED_PARENT_FLAG_KEYS: ReadonlyArray<keyof CommandFlags> = [
29+
'platform',
30+
'target',
31+
'device',
32+
'udid',
33+
'serial',
34+
'verbose',
35+
'out',
36+
];
37+
38+
/**
39+
* Merge parent flag values into child flags for keys that are undefined in the child.
40+
*/
41+
export function mergeParentFlags(
42+
parentFlags: CommandFlags | undefined,
43+
childFlags: CommandFlags,
44+
): CommandFlags {
45+
const parentRecord = (parentFlags ?? {}) as Record<string, unknown>;
46+
const childRecord = childFlags as Record<string, unknown>;
47+
for (const key of INHERITED_PARENT_FLAG_KEYS) {
48+
if (childRecord[key] === undefined && parentRecord[key] !== undefined) {
49+
childRecord[key] = parentRecord[key];
50+
}
51+
}
52+
return childFlags;
53+
}

src/daemon/handlers/install-source.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
1313
import { resolveInstallFromSourceResultTarget } from '../../client-shared.ts';
1414
import { AppError, normalizeError } from '../../utils/errors.ts';
1515
import { withSuccessText } from '../../utils/success-text.ts';
16+
import { unsupportedOperationResponse } from './response.ts';
1617

1718
function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | undefined {
1819
return platform === 'ios' || platform === 'android' ? platform : undefined;
@@ -73,13 +74,7 @@ export async function handleInstallFromSourceCommand(params: {
7374
flags: req.flags,
7475
});
7576
if (!isCommandSupportedOnDevice('install', device)) {
76-
return {
77-
ok: false,
78-
error: {
79-
code: 'UNSUPPORTED_OPERATION',
80-
message: 'install_from_source is not supported on this device',
81-
},
82-
};
77+
return unsupportedOperationResponse('install_from_source');
8378
}
8479

8580
const requestSignal = getRequestSignal(req.meta?.requestId);

src/daemon/handlers/interaction-fill.ts

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-f
2121
import { resolveRefTargetWithRectRefresh, type ResolveRefTarget } from './interaction-targeting.ts';
2222
import { unsupportedMacOsDesktopSurfaceInteraction } from './interaction-touch-policy.ts';
2323
import type { RefSnapshotFlagGuardResponse } from './interaction-flags.ts';
24+
import { errorResponse, sessionNotFoundResponse, unsupportedOperationResponse } from './response.ts';
2425

2526
export async function handleFillCommand(params: {
2627
req: DaemonRequest;
@@ -49,17 +50,11 @@ export async function handleFillCommand(params: {
4950
}
5051
}
5152
if (session && !isCommandSupportedOnDevice('fill', session.device)) {
52-
return {
53-
ok: false,
54-
error: { code: 'UNSUPPORTED_OPERATION', message: 'fill is not supported on this device' },
55-
};
53+
return unsupportedOperationResponse('fill');
5654
}
5755
if (req.positionals?.[0]?.startsWith('@')) {
5856
if (!session) {
59-
return {
60-
ok: false,
61-
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
62-
};
57+
return sessionNotFoundResponse();
6358
}
6459
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
6560
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
@@ -70,10 +65,7 @@ export async function handleFillCommand(params: {
7065
? req.positionals.slice(2).join(' ')
7166
: req.positionals.slice(1).join(' ');
7267
if (!text) {
73-
return {
74-
ok: false,
75-
error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' },
76-
};
68+
return errorResponse('INVALID_ARGS', 'fill requires text after ref');
7769
}
7870

7971
const resolvedRefFillTarget = await resolveRefTargetWithRectRefresh({
@@ -140,28 +132,19 @@ export async function handleFillCommand(params: {
140132
}
141133

142134
if (!session) {
143-
return {
144-
ok: false,
145-
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
146-
};
135+
return sessionNotFoundResponse();
147136
}
148137

149138
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], {
150139
preferTrailingValue: true,
151140
});
152141
if (selectorArgs) {
153142
if (selectorArgs.rest.length === 0) {
154-
return {
155-
ok: false,
156-
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
157-
};
143+
return errorResponse('INVALID_ARGS', 'fill requires text after selector');
158144
}
159145
const text = selectorArgs.rest.join(' ').trim();
160146
if (!text) {
161-
return {
162-
ok: false,
163-
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
164-
};
147+
return errorResponse('INVALID_ARGS', 'fill requires text after selector');
165148
}
166149

167150
const chain = parseSelectorChain(selectorArgs.selectorExpression);
@@ -184,13 +167,7 @@ export async function handleFillCommand(params: {
184167
{ command: req.command },
185168
);
186169
if (!resolved || !resolved.node.rect) {
187-
return {
188-
ok: false,
189-
error: {
190-
code: 'COMMAND_FAILED',
191-
message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }),
192-
},
193-
};
170+
return errorResponse('COMMAND_FAILED', formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }));
194171
}
195172

196173
const node = resolved.node;
@@ -235,11 +212,5 @@ export async function handleFillCommand(params: {
235212
});
236213
}
237214

238-
return {
239-
ok: false,
240-
error: {
241-
code: 'INVALID_ARGS',
242-
message: 'fill requires x y text, @ref text, or selector text',
243-
},
244-
};
215+
return errorResponse('INVALID_ARGS', 'fill requires x y text, @ref text, or selector text');
245216
}

src/daemon/handlers/interaction-flags.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CommandFlags } from '../../core/dispatch.ts';
22
import type { DaemonResponse } from '../types.ts';
3+
import { errorResponse } from './response.ts';
34

45
const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
56
['snapshotDepth', '--depth'],
@@ -13,13 +14,7 @@ export function refSnapshotFlagGuardResponse(
1314
): DaemonResponse | null {
1415
const unsupported = unsupportedRefSnapshotFlags(flags);
1516
if (unsupported.length === 0) return null;
16-
return {
17-
ok: false,
18-
error: {
19-
code: 'INVALID_ARGS',
20-
message: `${command} @ref does not support ${unsupported.join(', ')}.`,
21-
},
22-
};
17+
return errorResponse('INVALID_ARGS', `${command} @ref does not support ${unsupported.join(', ')}.`);
2318
}
2419

2520
export type RefSnapshotFlagGuardResponse = typeof refSnapshotFlagGuardResponse;

src/daemon/handlers/interaction-get.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,22 @@ import { refSnapshotFlagGuardResponse } from './interaction-flags.ts';
66
import { readTextForNode } from './interaction-read.ts';
77
import { resolveRefTarget } from './interaction-targeting.ts';
88
import { resolveSelectorTarget } from './interaction-selector.ts';
9+
import {
10+
errorResponse,
11+
sessionNotFoundResponse,
12+
unsupportedOperationResponse,
13+
} from './response.ts';
914

1015
export async function handleGetCommand(params: InteractionHandlerParams): Promise<DaemonResponse> {
1116
const { req, sessionName, sessionStore, contextFromFlags } = params;
1217
const sub = req.positionals?.[0];
1318
if (sub !== 'text' && sub !== 'attrs') {
14-
return {
15-
ok: false,
16-
error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' },
17-
};
19+
return errorResponse('INVALID_ARGS', 'get only supports text or attrs');
1820
}
1921
const session = sessionStore.get(sessionName);
20-
if (!session) {
21-
return {
22-
ok: false,
23-
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
24-
};
25-
}
22+
if (!session) return sessionNotFoundResponse();
2623
if (!isCommandSupportedOnDevice('get', session.device)) {
27-
return {
28-
ok: false,
29-
error: { code: 'UNSUPPORTED_OPERATION', message: 'get is not supported on this device' },
30-
};
24+
return unsupportedOperationResponse('get');
3125
}
3226
const refInput = req.positionals?.[1] ?? '';
3327
if (refInput.startsWith('@')) {
@@ -77,10 +71,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
7771

7872
const selectorExpression = req.positionals.slice(1).join(' ').trim();
7973
if (!selectorExpression) {
80-
return {
81-
ok: false,
82-
error: { code: 'INVALID_ARGS', message: 'get requires @ref or selector expression' },
83-
};
74+
return errorResponse('INVALID_ARGS', 'get requires @ref or selector expression');
8475
}
8576
const resolvedSelectorTarget = await resolveSelectorTarget({
8677
command: req.command,

0 commit comments

Comments
 (0)