Skip to content

Commit 20021c3

Browse files
authored
refactor: reduce duplication and simplify codebase (#374)
* 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 * refactor: deeper structural simplification pass Bigger wins from restructuring, not just mechanical dedup: - cli.ts: move logTailStopper to try/finally (eliminates 28 duplicate calls), extract writeCommandCliOutput/writeLogsCliOutput/writeNetworkCliOutput from 350-line if/else chain, fix remaining throwDaemonError site - session-store.ts: replace 67-line sanitizeFlags destructure/reconstruct with 10-line pick-from-array loop - record-trace: extract finalizeRecordingOverlay helper, replacing 4 copies of the telemetry+overlay block across ios/android/recording files - Deduplicate normalizeText (finders.ts + selectors-match.ts) - Fix isEnvTruthy to preserve whitespace-tolerant parsing (.trim()) * fix: ensure logTailStopper runs before process.exit process.exit() does not unwind the stack, so finally blocks are skipped. Restore explicit logTailStopper() calls before each process.exit() to prevent leaking the background daemon log tail process. The finally block remains as a safety net for normal return paths. Also refactor writeCommandCliOutput to return an exit code instead of calling process.exit() directly, keeping the exit decision in the caller where cleanup is visible. * refactor: simplify daemon failure responses * refactor: remove redundant daemon response cast
1 parent be001e1 commit 20021c3

55 files changed

Lines changed: 895 additions & 1560 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: 279 additions & 360 deletions
Large diffs are not rendered by default.

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,
@@ -58,12 +58,7 @@ export function createAgentDeviceClient(
5858
meta: buildMeta(merged),
5959
});
6060
if (!response.ok) {
61-
throw new AppError(response.error.code as any, response.error.message, {
62-
...(response.error.details ?? {}),
63-
hint: response.error.hint,
64-
diagnosticId: response.error.diagnosticId,
65-
logPath: response.error.logPath,
66-
});
61+
throwDaemonError(response.error);
6762
}
6863
return (response.data ?? {}) as Record<string, unknown>;
6964
};

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: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 5 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 { errorResponse } from './response.ts';
1617

1718
function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | undefined {
1819
return platform === 'ios' || platform === 'android' ? platform : undefined;
@@ -73,13 +74,10 @@ 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 errorResponse(
78+
'UNSUPPORTED_OPERATION',
79+
'install_from_source is not supported on this device',
80+
);
8381
}
8482

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

src/daemon/handlers/interaction-fill.ts

Lines changed: 13 additions & 39 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 } 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 errorResponse('UNSUPPORTED_OPERATION', 'fill is not supported on this device');
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 errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
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({
@@ -91,7 +83,7 @@ export async function handleFillCommand(params: {
9183
captureSnapshotForSession,
9284
resolveRefTarget,
9385
});
94-
if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget.response;
86+
if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget;
9587

9688
const { ref, node, snapshotNodes, point } = resolvedRefFillTarget.target;
9789
const nodeType = node.type ?? '';
@@ -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 errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
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,10 @@ 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(
171+
'COMMAND_FAILED',
172+
formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }),
173+
);
194174
}
195175

196176
const node = resolved.node;
@@ -235,11 +215,5 @@ export async function handleFillCommand(params: {
235215
});
236216
}
237217

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-
};
218+
return errorResponse('INVALID_ARGS', 'fill requires x y text, @ref text, or selector text');
245219
}

src/daemon/handlers/interaction-flags.ts

Lines changed: 5 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,10 @@ 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(
18+
'INVALID_ARGS',
19+
`${command} @ref does not support ${unsupported.join(', ')}.`,
20+
);
2321
}
2422

2523
export type RefSnapshotFlagGuardResponse = typeof refSnapshotFlagGuardResponse;

src/daemon/handlers/interaction-get.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,18 @@ 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 { errorResponse } from './response.ts';
910

1011
export async function handleGetCommand(params: InteractionHandlerParams): Promise<DaemonResponse> {
1112
const { req, sessionName, sessionStore, contextFromFlags } = params;
1213
const sub = req.positionals?.[0];
1314
if (sub !== 'text' && sub !== 'attrs') {
14-
return {
15-
ok: false,
16-
error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' },
17-
};
15+
return errorResponse('INVALID_ARGS', 'get only supports text or attrs');
1816
}
1917
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-
}
18+
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
2619
if (!isCommandSupportedOnDevice('get', session.device)) {
27-
return {
28-
ok: false,
29-
error: { code: 'UNSUPPORTED_OPERATION', message: 'get is not supported on this device' },
30-
};
20+
return errorResponse('UNSUPPORTED_OPERATION', 'get is not supported on this device');
3121
}
3222
const refInput = req.positionals?.[1] ?? '';
3323
if (refInput.startsWith('@')) {
@@ -43,7 +33,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
4333
invalidRefMessage: 'get text requires a ref like @e2',
4434
notFoundMessage: `Ref ${refInput} not found`,
4535
});
46-
if (!resolvedRefTarget.ok) return resolvedRefTarget.response;
36+
if (!resolvedRefTarget.ok) return resolvedRefTarget;
4737
const { ref, node } = resolvedRefTarget.target;
4838
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
4939
action: 'get',
@@ -77,10 +67,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
7767

7868
const selectorExpression = req.positionals.slice(1).join(' ').trim();
7969
if (!selectorExpression) {
80-
return {
81-
ok: false,
82-
error: { code: 'INVALID_ARGS', message: 'get requires @ref or selector expression' },
83-
};
70+
return errorResponse('INVALID_ARGS', 'get requires @ref or selector expression');
8471
}
8572
const resolvedSelectorTarget = await resolveSelectorTarget({
8673
command: req.command,
@@ -94,7 +81,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis
9481
requireUnique: true,
9582
disambiguateAmbiguous: sub === 'text',
9683
});
97-
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response;
84+
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget;
9885
const { resolved } = resolvedSelectorTarget;
9986
const node = resolved.node;
10087
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {

0 commit comments

Comments
 (0)