Skip to content

Commit a3b96d7

Browse files
committed
refactor: dedupe replay and interaction series helpers
1 parent ee2935e commit a3b96d7

5 files changed

Lines changed: 196 additions & 194 deletions

File tree

src/core/dispatch.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,13 @@ export async function dispatchCommand(
150150
return { x, y, count, intervalMs, holdMs, jitterPx, timingMode: 'runner-series' };
151151
}
152152

153-
for (let index = 0; index < count; index += 1) {
153+
await runRepeatedSeries(count, intervalMs, async (index) => {
154154
const [dx, dy] = computeDeterministicJitter(index, jitterPx);
155155
const targetX = x + dx;
156156
const targetY = y + dy;
157157
if (holdMs > 0) await interactor.longPress(targetX, targetY, holdMs);
158158
else await interactor.tap(targetX, targetY);
159-
if (index < count - 1 && intervalMs > 0) await sleep(intervalMs);
160-
}
159+
});
161160

162161
return { x, y, count, intervalMs, holdMs, jitterPx };
163162
}
@@ -211,12 +210,11 @@ export async function dispatchCommand(
211210
};
212211
}
213212

214-
for (let index = 0; index < count; index += 1) {
213+
await runRepeatedSeries(count, pauseMs, async (index) => {
215214
const reverse = pattern === 'ping-pong' && index % 2 === 1;
216215
if (reverse) await interactor.swipe(x2, y2, x1, y1, effectiveDurationMs);
217216
else await interactor.swipe(x1, y1, x2, y2, effectiveDurationMs);
218-
if (index < count - 1 && pauseMs > 0) await sleep(pauseMs);
219-
}
217+
});
220218

221219
return {
222220
x1,
@@ -420,6 +418,19 @@ function computeDeterministicJitter(index: number, jitterPx: number): [number, n
420418
return [dx * jitterPx, dy * jitterPx];
421419
}
422420

421+
async function runRepeatedSeries(
422+
count: number,
423+
pauseMs: number,
424+
operation: (index: number) => Promise<void>,
425+
): Promise<void> {
426+
for (let index = 0; index < count; index += 1) {
427+
await operation(index);
428+
if (index < count - 1 && pauseMs > 0) {
429+
await sleep(pauseMs);
430+
}
431+
}
432+
}
433+
423434
async function sleep(ms: number): Promise<void> {
424435
await new Promise((resolve) => setTimeout(resolve, ms));
425436
}

src/daemon/handlers/interaction.ts

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
66
import { SessionStore } from '../session-store.ts';
77
import { evaluateIsPredicate, isSupportedPredicate } from '../is-predicates.ts';
88
import { extractNodeText, findNodeByLabel, isFillableType, pruneGroupNodes, resolveRefLabel } from '../snapshot-processing.ts';
9+
import { isClickLikeCommand } from '../script-utils.ts';
910
import {
1011
buildSelectorChainForNode,
1112
findSelectorChainMatch,
@@ -33,7 +34,7 @@ export async function handleInteractionCommands(params: {
3334
const dispatch = params.dispatch ?? dispatchCommand;
3435
const command = req.command;
3536

36-
if (command === 'click' || command === 'press') {
37+
if (isClickLikeCommand(command)) {
3738
const session = sessionStore.get(sessionName);
3839
if (!session) {
3940
return {
@@ -114,9 +115,14 @@ export async function handleInteractionCommands(params: {
114115
};
115116
}
116117
const chain = parseSelectorChain(selectorExpression);
117-
const snapshot = await captureSnapshotForSession(session, req.flags, sessionStore, contextFromFlags, {
118-
interactiveOnly: true,
119-
});
118+
const snapshot = await captureSnapshotForSession(
119+
session,
120+
req.flags,
121+
sessionStore,
122+
contextFromFlags,
123+
{ interactiveOnly: true },
124+
dispatch,
125+
);
120126
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
121127
platform: session.device.platform,
122128
requireRect: true,
@@ -185,7 +191,7 @@ export async function handleInteractionCommands(params: {
185191
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
186192
const selectorChain = buildSelectorChainForNode(node, session.device.platform, { action: 'fill' });
187193
const { x, y } = centerOfRect(node.rect);
188-
const data = await dispatchCommand(
194+
const data = await dispatch(
189195
session.device,
190196
'fill',
191197
[String(x), String(y), text],
@@ -224,9 +230,14 @@ export async function handleInteractionCommands(params: {
224230
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
225231
}
226232
const chain = parseSelectorChain(selectorArgs.selectorExpression);
227-
const snapshot = await captureSnapshotForSession(session, req.flags, sessionStore, contextFromFlags, {
228-
interactiveOnly: true,
229-
});
233+
const snapshot = await captureSnapshotForSession(
234+
session,
235+
req.flags,
236+
sessionStore,
237+
contextFromFlags,
238+
{ interactiveOnly: true },
239+
dispatch,
240+
);
230241
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
231242
platform: session.device.platform,
232243
requireRect: true,
@@ -249,7 +260,7 @@ export async function handleInteractionCommands(params: {
249260
? `fill target ${resolved.selector.raw} resolved to "${nodeType}", attempting fill anyway.`
250261
: undefined;
251262
const { x, y } = centerOfRect(resolved.node.rect);
252-
const data = await dispatchCommand(session.device, 'fill', [String(x), String(y), text], req.flags?.out, {
263+
const data = await dispatch(session.device, 'fill', [String(x), String(y), text], req.flags?.out, {
253264
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
254265
});
255266
const selectorChain = buildSelectorChainForNode(node, session.device.platform, { action: 'fill' });
@@ -337,9 +348,14 @@ export async function handleInteractionCommands(params: {
337348
};
338349
}
339350
const chain = parseSelectorChain(selectorExpression);
340-
const snapshot = await captureSnapshotForSession(session, req.flags, sessionStore, contextFromFlags, {
341-
interactiveOnly: false,
342-
});
351+
const snapshot = await captureSnapshotForSession(
352+
session,
353+
req.flags,
354+
sessionStore,
355+
contextFromFlags,
356+
{ interactiveOnly: false },
357+
dispatch,
358+
);
343359
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
344360
platform: session.device.platform,
345361
requireRect: false,
@@ -435,9 +451,14 @@ export async function handleInteractionCommands(params: {
435451
};
436452
}
437453
const chain = parseSelectorChain(split.selectorExpression);
438-
const snapshot = await captureSnapshotForSession(session, req.flags, sessionStore, contextFromFlags, {
439-
interactiveOnly: false,
440-
});
454+
const snapshot = await captureSnapshotForSession(
455+
session,
456+
req.flags,
457+
sessionStore,
458+
contextFromFlags,
459+
{ interactiveOnly: false },
460+
dispatch,
461+
);
441462
if (predicate === 'exists') {
442463
const matched = findSelectorChainMatch(snapshot.nodes, chain, {
443464
platform: session.device.platform,
@@ -518,8 +539,9 @@ async function captureSnapshotForSession(
518539
sessionStore: SessionStore,
519540
contextFromFlags: ContextFromFlags,
520541
options: { interactiveOnly: boolean },
542+
dispatch: typeof dispatchCommand = dispatchCommand,
521543
) {
522-
const data = (await dispatchCommand(session.device, 'snapshot', [], flags?.out, {
544+
const data = (await dispatch(session.device, 'snapshot', [], flags?.out, {
523545
...contextFromFlags(
524546
{
525547
...(flags ?? {}),

src/daemon/handlers/session.ts

Lines changed: 21 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import {
1919
tryParseSelectorChain,
2020
} from '../selectors.ts';
2121
import { inferFillText, uniqueStrings } from '../action-utils.ts';
22+
import {
23+
appendScriptSeriesFlags,
24+
formatScriptActionSummary,
25+
formatScriptArg,
26+
isClickLikeCommand,
27+
parseReplaySeriesFlags,
28+
} from '../script-utils.ts';
2229

2330
type ReinstallOps = {
2431
ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
@@ -649,13 +656,7 @@ function withReplayFailureContext(
649656
}
650657

651658
function formatReplayActionSummary(action: SessionAction): string {
652-
const values = (action.positionals ?? []).map((value) => {
653-
const trimmed = value.trim();
654-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
655-
if (trimmed.startsWith('@')) return trimmed;
656-
return JSON.stringify(trimmed);
657-
});
658-
return [action.command, ...values].join(' ');
659+
return formatScriptActionSummary(action);
659660
}
660661

661662
async function healReplayAction(params: {
@@ -666,13 +667,12 @@ async function healReplayAction(params: {
666667
dispatch: typeof dispatchCommand;
667668
}): Promise<SessionAction | null> {
668669
const { action, sessionName, logPath, sessionStore, dispatch } = params;
669-
if (!['click', 'press', 'fill', 'get', 'is', 'wait'].includes(action.command)) return null;
670+
if (!(isClickLikeCommand(action.command) || ['fill', 'get', 'is', 'wait'].includes(action.command))) return null;
670671
const session = sessionStore.get(sessionName);
671672
if (!session) return null;
672-
const requiresRect = action.command === 'click' || action.command === 'press' || action.command === 'fill';
673+
const requiresRect = isClickLikeCommand(action.command) || action.command === 'fill';
673674
const allowDisambiguation =
674-
action.command === 'click' ||
675-
action.command === 'press' ||
675+
isClickLikeCommand(action.command) ||
676676
action.command === 'fill' ||
677677
(action.command === 'get' && action.positionals?.[0] === 'text');
678678
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
@@ -688,15 +688,10 @@ async function healReplayAction(params: {
688688
});
689689
if (!resolved) continue;
690690
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
691-
action:
692-
action.command === 'click' || action.command === 'press'
693-
? 'click'
694-
: action.command === 'fill'
695-
? 'fill'
696-
: 'get',
691+
action: isClickLikeCommand(action.command) ? 'click' : action.command === 'fill' ? 'fill' : 'get',
697692
});
698693
const selectorExpression = selectorChain.join(' || ');
699-
if (action.command === 'click' || action.command === 'press') {
694+
if (isClickLikeCommand(action.command)) {
700695
return {
701696
...action,
702697
positionals: [selectorExpression],
@@ -796,7 +791,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
796791
: [];
797792
result.push(...explicitChain);
798793

799-
if (action.command === 'click' || action.command === 'press') {
794+
if (isClickLikeCommand(action.command)) {
800795
const first = action.positionals?.[0] ?? '';
801796
if (first && !first.startsWith('@')) {
802797
result.push(action.positionals.join(' '));
@@ -992,8 +987,8 @@ function parseReplayScriptLine(line: string): SessionAction | null {
992987
return action;
993988
}
994989

995-
if (command === 'click' || command === 'press') {
996-
const parsed = parseReplayPressFlags(args);
990+
if (isClickLikeCommand(command)) {
991+
const parsed = parseReplaySeriesFlags(command, args);
997992
Object.assign(action.flags, parsed.flags);
998993
if (parsed.positionals.length === 0) return action;
999994
const target = parsed.positionals[0];
@@ -1052,7 +1047,7 @@ function parseReplayScriptLine(line: string): SessionAction | null {
10521047
}
10531048

10541049
if (command === 'swipe') {
1055-
const parsed = parseReplaySwipeFlags(args);
1050+
const parsed = parseReplaySeriesFlags(command, args);
10561051
Object.assign(action.flags, parsed.flags);
10571052
action.positionals = parsed.positionals;
10581053
return action;
@@ -1062,81 +1057,6 @@ function parseReplayScriptLine(line: string): SessionAction | null {
10621057
return action;
10631058
}
10641059

1065-
function parseReplayPressFlags(args: string[]): { positionals: string[]; flags: SessionAction['flags'] } {
1066-
const positionals: string[] = [];
1067-
const flags: SessionAction['flags'] = {};
1068-
for (let index = 0; index < args.length; index += 1) {
1069-
const token = args[index];
1070-
if (token === '--tap-batch') {
1071-
flags.tapBatch = true;
1072-
continue;
1073-
}
1074-
if (token === '--count' && index + 1 < args.length) {
1075-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1076-
if (parsed !== null) flags.count = parsed;
1077-
index += 1;
1078-
continue;
1079-
}
1080-
if (token === '--interval-ms' && index + 1 < args.length) {
1081-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1082-
if (parsed !== null) flags.intervalMs = parsed;
1083-
index += 1;
1084-
continue;
1085-
}
1086-
if (token === '--hold-ms' && index + 1 < args.length) {
1087-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1088-
if (parsed !== null) flags.holdMs = parsed;
1089-
index += 1;
1090-
continue;
1091-
}
1092-
if (token === '--jitter-px' && index + 1 < args.length) {
1093-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1094-
if (parsed !== null) flags.jitterPx = parsed;
1095-
index += 1;
1096-
continue;
1097-
}
1098-
positionals.push(token);
1099-
}
1100-
return { positionals, flags };
1101-
}
1102-
1103-
function parseReplaySwipeFlags(args: string[]): { positionals: string[]; flags: SessionAction['flags'] } {
1104-
const positionals: string[] = [];
1105-
const flags: SessionAction['flags'] = {};
1106-
for (let index = 0; index < args.length; index += 1) {
1107-
const token = args[index];
1108-
if (token === '--count' && index + 1 < args.length) {
1109-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1110-
if (parsed !== null) flags.count = parsed;
1111-
index += 1;
1112-
continue;
1113-
}
1114-
if (token === '--pause-ms' && index + 1 < args.length) {
1115-
const parsed = parseNonNegativeIntToken(args[index + 1]);
1116-
if (parsed !== null) flags.pauseMs = parsed;
1117-
index += 1;
1118-
continue;
1119-
}
1120-
if (token === '--pattern' && index + 1 < args.length) {
1121-
const pattern = args[index + 1];
1122-
if (pattern === 'one-way' || pattern === 'ping-pong') {
1123-
flags.pattern = pattern;
1124-
}
1125-
index += 1;
1126-
continue;
1127-
}
1128-
positionals.push(token);
1129-
}
1130-
return { positionals, flags };
1131-
}
1132-
1133-
function parseNonNegativeIntToken(token: string | undefined): number | null {
1134-
if (!token) return null;
1135-
const value = Number(token);
1136-
if (!Number.isFinite(value) || value < 0) return null;
1137-
return Math.floor(value);
1138-
}
1139-
11401060
function isNumericToken(token: string | undefined): token is string {
11411061
if (!token) return false;
11421062
return !Number.isNaN(Number(token));
@@ -1205,49 +1125,23 @@ function formatReplayActionLine(action: SessionAction): string {
12051125
parts.push('-d', String(action.flags.snapshotDepth));
12061126
}
12071127
if (action.flags?.snapshotScope) {
1208-
parts.push('-s', formatReplayArg(action.flags.snapshotScope));
1128+
parts.push('-s', formatScriptArg(action.flags.snapshotScope));
12091129
}
12101130
if (action.flags?.snapshotRaw) parts.push('--raw');
12111131
return parts.join(' ');
12121132
}
12131133
if (action.command === 'open') {
12141134
for (const positional of action.positionals ?? []) {
1215-
parts.push(formatReplayArg(positional));
1135+
parts.push(formatScriptArg(positional));
12161136
}
12171137
if (action.flags?.relaunch) {
12181138
parts.push('--relaunch');
12191139
}
12201140
return parts.join(' ');
12211141
}
12221142
for (const positional of action.positionals ?? []) {
1223-
parts.push(formatReplayArg(positional));
1143+
parts.push(formatScriptArg(positional));
12241144
}
1225-
appendReplaySeriesFlags(parts, action);
1145+
appendScriptSeriesFlags(parts, action);
12261146
return parts.join(' ');
12271147
}
1228-
1229-
function appendReplaySeriesFlags(parts: string[], action: SessionAction): void {
1230-
const flags = action.flags ?? {};
1231-
if (action.command === 'click' || action.command === 'press') {
1232-
if (typeof flags.count === 'number') parts.push('--count', String(flags.count));
1233-
if (typeof flags.intervalMs === 'number') parts.push('--interval-ms', String(flags.intervalMs));
1234-
if (typeof flags.holdMs === 'number') parts.push('--hold-ms', String(flags.holdMs));
1235-
if (typeof flags.jitterPx === 'number') parts.push('--jitter-px', String(flags.jitterPx));
1236-
if (flags.tapBatch === true) parts.push('--tap-batch');
1237-
return;
1238-
}
1239-
if (action.command === 'swipe') {
1240-
if (typeof flags.count === 'number') parts.push('--count', String(flags.count));
1241-
if (typeof flags.pauseMs === 'number') parts.push('--pause-ms', String(flags.pauseMs));
1242-
if (flags.pattern === 'one-way' || flags.pattern === 'ping-pong') {
1243-
parts.push('--pattern', flags.pattern);
1244-
}
1245-
}
1246-
}
1247-
1248-
function formatReplayArg(value: string): string {
1249-
const trimmed = value.trim();
1250-
if (trimmed.startsWith('@')) return trimmed;
1251-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
1252-
return JSON.stringify(trimmed);
1253-
}

0 commit comments

Comments
 (0)