Skip to content

Commit 37b82ab

Browse files
authored
Refactor interaction handler into command modules (#248)
* refactor: split interaction command handlers * fix: avoid duplicate snapshot in is handler
1 parent 5f62ef4 commit 37b82ab

12 files changed

Lines changed: 1079 additions & 911 deletions

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,3 +524,53 @@ test('scrollintoview @ref does not run post-scroll verification snapshot', async
524524
assert.equal(response.ok, true);
525525
assert.equal(snapshotCallCount, 0);
526526
});
527+
528+
test('is visible captures one snapshot before evaluating selector predicate', async () => {
529+
const sessionStore = makeSessionStore();
530+
const sessionName = 'default';
531+
sessionStore.set(sessionName, makeSession(sessionName));
532+
533+
let snapshotCallCount = 0;
534+
const response = await handleInteractionCommands({
535+
req: {
536+
token: 't',
537+
session: sessionName,
538+
command: 'is',
539+
positionals: ['visible', 'id=auth_continue'],
540+
flags: {},
541+
},
542+
sessionName,
543+
sessionStore,
544+
contextFromFlags,
545+
dispatch: async (_device, command) => {
546+
if (command === 'snapshot') {
547+
snapshotCallCount += 1;
548+
return {
549+
nodes: [
550+
{
551+
index: 0,
552+
type: 'XCUIElementTypeButton',
553+
label: 'Continue',
554+
identifier: 'auth_continue',
555+
rect: { x: 10, y: 20, width: 100, height: 40 },
556+
enabled: true,
557+
hittable: true,
558+
visible: true,
559+
},
560+
],
561+
backend: 'xctest',
562+
};
563+
}
564+
throw new Error(`unexpected command: ${command}`);
565+
},
566+
});
567+
568+
assert.ok(response);
569+
assert.equal(response.ok, true);
570+
assert.equal(snapshotCallCount, 1);
571+
if (response.ok) {
572+
assert.equal(response.data?.predicate, 'visible');
573+
assert.equal(response.data?.pass, true);
574+
assert.equal(response.data?.selector, 'id=auth_continue');
575+
}
576+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
2+
import type { DaemonCommandContext } from '../context.ts';
3+
import type { DaemonRequest } from '../types.ts';
4+
import { SessionStore } from '../session-store.ts';
5+
6+
export type ContextFromFlags = (
7+
flags: CommandFlags | undefined,
8+
appBundleId?: string,
9+
traceLogPath?: string,
10+
) => DaemonCommandContext;
11+
12+
export type InteractionHandlerParams = {
13+
req: DaemonRequest;
14+
sessionName: string;
15+
sessionStore: SessionStore;
16+
contextFromFlags: ContextFromFlags;
17+
dispatch: typeof dispatchCommand;
18+
};
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
2+
import { centerOfRect } from '../../utils/snapshot.ts';
3+
import { buildSelectorChainForNode, splitSelectorFromArgs } from '../selectors.ts';
4+
import { isFillableType, resolveRefLabel } from '../snapshot-processing.ts';
5+
import type { DaemonResponse } from '../types.ts';
6+
import type { InteractionHandlerParams } from './interaction-common.ts';
7+
import { refSnapshotFlagGuardResponse } from './interaction-flags.ts';
8+
import { resolveRefTarget } from './interaction-targeting.ts';
9+
import { resolveSelectorTarget } from './interaction-selector.ts';
10+
11+
export async function handleFillCommand(params: InteractionHandlerParams): Promise<DaemonResponse> {
12+
const { req, sessionName, sessionStore, contextFromFlags, dispatch } = params;
13+
const session = sessionStore.get(sessionName);
14+
if (session && !isCommandSupportedOnDevice('fill', session.device)) {
15+
return {
16+
ok: false,
17+
error: { code: 'UNSUPPORTED_OPERATION', message: 'fill is not supported on this device' },
18+
};
19+
}
20+
if (req.positionals?.[0]?.startsWith('@')) {
21+
if (!session) {
22+
return {
23+
ok: false,
24+
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
25+
};
26+
}
27+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
28+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
29+
const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
30+
const text =
31+
req.positionals.length >= 3
32+
? req.positionals.slice(2).join(' ')
33+
: req.positionals.slice(1).join(' ');
34+
if (!text) {
35+
return {
36+
ok: false,
37+
error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' },
38+
};
39+
}
40+
const resolvedRefTarget = resolveRefTarget({
41+
session,
42+
refInput: req.positionals[0],
43+
fallbackLabel: labelCandidate,
44+
requireRect: true,
45+
invalidRefMessage: 'fill requires a ref like @e2',
46+
notFoundMessage: `Ref ${req.positionals[0]} not found or has no bounds`,
47+
});
48+
if (!resolvedRefTarget.ok) return resolvedRefTarget.response;
49+
const { ref, node, snapshotNodes } = resolvedRefTarget.target;
50+
if (!node.rect) {
51+
return {
52+
ok: false,
53+
error: {
54+
code: 'COMMAND_FAILED',
55+
message: `Ref ${req.positionals[0]} not found or has no bounds`,
56+
},
57+
};
58+
}
59+
const nodeType = node.type ?? '';
60+
const fillWarning =
61+
nodeType && !isFillableType(nodeType, session.device.platform)
62+
? `fill target ${req.positionals[0]} resolved to "${nodeType}", attempting fill anyway.`
63+
: undefined;
64+
const refLabel = resolveRefLabel(node, snapshotNodes);
65+
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
66+
action: 'fill',
67+
});
68+
const { x, y } = centerOfRect(node.rect);
69+
const data = await dispatch(
70+
session.device,
71+
'fill',
72+
[String(x), String(y), text],
73+
req.flags?.out,
74+
{
75+
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
76+
},
77+
);
78+
const resultPayload: Record<string, unknown> = {
79+
...(data ?? { ref, x, y }),
80+
};
81+
if (fillWarning) {
82+
resultPayload.warning = fillWarning;
83+
}
84+
sessionStore.recordAction(session, {
85+
command: req.command,
86+
positionals: req.positionals ?? [],
87+
flags: req.flags ?? {},
88+
result: { ...resultPayload, refLabel, selectorChain },
89+
});
90+
return { ok: true, data: resultPayload };
91+
}
92+
if (!session) {
93+
return {
94+
ok: false,
95+
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
96+
};
97+
}
98+
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], {
99+
preferTrailingValue: true,
100+
});
101+
if (!selectorArgs) {
102+
return {
103+
ok: false,
104+
error: {
105+
code: 'INVALID_ARGS',
106+
message: 'fill requires x y text, @ref text, or selector text',
107+
},
108+
};
109+
}
110+
if (selectorArgs.rest.length === 0) {
111+
return {
112+
ok: false,
113+
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
114+
};
115+
}
116+
const text = selectorArgs.rest.join(' ').trim();
117+
if (!text) {
118+
return {
119+
ok: false,
120+
error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' },
121+
};
122+
}
123+
const resolvedSelectorTarget = await resolveSelectorTarget({
124+
command: req.command,
125+
selectorExpression: selectorArgs.selectorExpression,
126+
session,
127+
flags: req.flags,
128+
sessionStore,
129+
contextFromFlags,
130+
interactiveOnly: true,
131+
requireRect: true,
132+
requireUnique: true,
133+
disambiguateAmbiguous: true,
134+
dispatch,
135+
});
136+
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response;
137+
const { resolved, snapshot } = resolvedSelectorTarget;
138+
const node = resolved.node;
139+
if (!node.rect) {
140+
return {
141+
ok: false,
142+
error: {
143+
code: 'COMMAND_FAILED',
144+
message: `Selector ${resolved.selector.raw} resolved to invalid bounds`,
145+
},
146+
};
147+
}
148+
const nodeType = node.type ?? '';
149+
const fillWarning =
150+
nodeType && !isFillableType(nodeType, session.device.platform)
151+
? `fill target ${resolved.selector.raw} resolved to "${nodeType}", attempting fill anyway.`
152+
: undefined;
153+
const { x, y } = centerOfRect(node.rect);
154+
const data = await dispatch(
155+
session.device,
156+
'fill',
157+
[String(x), String(y), text],
158+
req.flags?.out,
159+
{
160+
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
161+
},
162+
);
163+
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
164+
action: 'fill',
165+
});
166+
const resultPayload: Record<string, unknown> = {
167+
...(data ?? { x, y, text }),
168+
selector: resolved.selector.raw,
169+
selectorChain,
170+
refLabel: resolveRefLabel(node, snapshot.nodes),
171+
};
172+
if (fillWarning) {
173+
resultPayload.warning = fillWarning;
174+
}
175+
sessionStore.recordAction(session, {
176+
command: req.command,
177+
positionals: req.positionals ?? [],
178+
flags: req.flags ?? {},
179+
result: resultPayload,
180+
});
181+
return { ok: true, data: resultPayload };
182+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { CommandFlags } from '../../core/dispatch.ts';
2+
import type { DaemonResponse } from '../types.ts';
3+
4+
const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
5+
['snapshotDepth', '--depth'],
6+
['snapshotScope', '--scope'],
7+
['snapshotRaw', '--raw'],
8+
];
9+
10+
export function refSnapshotFlagGuardResponse(
11+
command: 'press' | 'fill' | 'get' | 'scrollintoview',
12+
flags: CommandFlags | undefined,
13+
): DaemonResponse | null {
14+
const unsupported = unsupportedRefSnapshotFlags(flags);
15+
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+
};
23+
}
24+
25+
export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
26+
if (!flags) return [];
27+
const unsupported: string[] = [];
28+
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
29+
if (flags[key] !== undefined) unsupported.push(label);
30+
}
31+
return unsupported;
32+
}

0 commit comments

Comments
 (0)