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
50 changes: 50 additions & 0 deletions src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,53 @@ test('scrollintoview @ref does not run post-scroll verification snapshot', async
assert.equal(response.ok, true);
assert.equal(snapshotCallCount, 0);
});

test('is visible captures one snapshot before evaluating selector predicate', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'default';
sessionStore.set(sessionName, makeSession(sessionName));

let snapshotCallCount = 0;
const response = await handleInteractionCommands({
req: {
token: 't',
session: sessionName,
command: 'is',
positionals: ['visible', 'id=auth_continue'],
flags: {},
},
sessionName,
sessionStore,
contextFromFlags,
dispatch: async (_device, command) => {
if (command === 'snapshot') {
snapshotCallCount += 1;
return {
nodes: [
{
index: 0,
type: 'XCUIElementTypeButton',
label: 'Continue',
identifier: 'auth_continue',
rect: { x: 10, y: 20, width: 100, height: 40 },
enabled: true,
hittable: true,
visible: true,
},
],
backend: 'xctest',
};
}
throw new Error(`unexpected command: ${command}`);
},
});

assert.ok(response);
assert.equal(response.ok, true);
assert.equal(snapshotCallCount, 1);
if (response.ok) {
assert.equal(response.data?.predicate, 'visible');
assert.equal(response.data?.pass, true);
assert.equal(response.data?.selector, 'id=auth_continue');
}
});
18 changes: 18 additions & 0 deletions src/daemon/handlers/interaction-common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
import type { DaemonCommandContext } from '../context.ts';
import type { DaemonRequest } from '../types.ts';
import { SessionStore } from '../session-store.ts';

export type ContextFromFlags = (
flags: CommandFlags | undefined,
appBundleId?: string,
traceLogPath?: string,
) => DaemonCommandContext;

export type InteractionHandlerParams = {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
contextFromFlags: ContextFromFlags;
dispatch: typeof dispatchCommand;
};
182 changes: 182 additions & 0 deletions src/daemon/handlers/interaction-fill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { centerOfRect } from '../../utils/snapshot.ts';
import { buildSelectorChainForNode, splitSelectorFromArgs } from '../selectors.ts';
import { isFillableType, resolveRefLabel } from '../snapshot-processing.ts';
import type { DaemonResponse } from '../types.ts';
import type { InteractionHandlerParams } from './interaction-common.ts';
import { refSnapshotFlagGuardResponse } from './interaction-flags.ts';
import { resolveRefTarget } from './interaction-targeting.ts';
import { resolveSelectorTarget } from './interaction-selector.ts';

export async function handleFillCommand(params: InteractionHandlerParams): Promise<DaemonResponse> {
const { req, sessionName, sessionStore, contextFromFlags, dispatch } = params;
const session = sessionStore.get(sessionName);
if (session && !isCommandSupportedOnDevice('fill', session.device)) {
return {
ok: false,
error: { code: 'UNSUPPORTED_OPERATION', message: '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.' },
};
}
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
const text =
req.positionals.length >= 3
? 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' },
};
}
const resolvedRefTarget = resolveRefTarget({
session,
refInput: req.positionals[0],
fallbackLabel: labelCandidate,
requireRect: true,
invalidRefMessage: 'fill requires a ref like @e2',
notFoundMessage: `Ref ${req.positionals[0]} not found or has no bounds`,
});
if (!resolvedRefTarget.ok) return resolvedRefTarget.response;
const { ref, node, snapshotNodes } = resolvedRefTarget.target;
if (!node.rect) {
return {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: `Ref ${req.positionals[0]} not found or has no bounds`,
},
};
}
const nodeType = node.type ?? '';
const fillWarning =
nodeType && !isFillableType(nodeType, session.device.platform)
? `fill target ${req.positionals[0]} resolved to "${nodeType}", attempting fill anyway.`
: undefined;
const refLabel = resolveRefLabel(node, snapshotNodes);
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
action: 'fill',
});
const { x, y } = centerOfRect(node.rect);
const data = await dispatch(
session.device,
'fill',
[String(x), String(y), text],
req.flags?.out,
{
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
},
);
const resultPayload: Record<string, unknown> = {
...(data ?? { ref, x, y }),
};
if (fillWarning) {
resultPayload.warning = fillWarning;
}
sessionStore.recordAction(session, {
command: req.command,
positionals: req.positionals ?? [],
flags: req.flags ?? {},
result: { ...resultPayload, refLabel, selectorChain },
});
return { ok: true, data: resultPayload };
}
if (!session) {
return {
ok: false,
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
};
}
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], {
preferTrailingValue: true,
});
if (!selectorArgs) {
return {
ok: false,
error: {
code: 'INVALID_ARGS',
message: 'fill requires x y text, @ref text, or selector text',
},
};
}
if (selectorArgs.rest.length === 0) {
return {
ok: false,
error: { code: 'INVALID_ARGS', message: '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' },
};
}
const resolvedSelectorTarget = await resolveSelectorTarget({
command: req.command,
selectorExpression: selectorArgs.selectorExpression,
session,
flags: req.flags,
sessionStore,
contextFromFlags,
interactiveOnly: true,
requireRect: true,
requireUnique: true,
disambiguateAmbiguous: true,
dispatch,
});
if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response;
const { resolved, snapshot } = resolvedSelectorTarget;
const node = resolved.node;
if (!node.rect) {
return {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: `Selector ${resolved.selector.raw} resolved to invalid bounds`,
},
};
}
const nodeType = node.type ?? '';
const fillWarning =
nodeType && !isFillableType(nodeType, session.device.platform)
? `fill target ${resolved.selector.raw} resolved to "${nodeType}", attempting fill anyway.`
: undefined;
const { x, y } = centerOfRect(node.rect);
const data = await dispatch(
session.device,
'fill',
[String(x), String(y), text],
req.flags?.out,
{
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
},
);
const selectorChain = buildSelectorChainForNode(node, session.device.platform, {
action: 'fill',
});
const resultPayload: Record<string, unknown> = {
...(data ?? { x, y, text }),
selector: resolved.selector.raw,
selectorChain,
refLabel: resolveRefLabel(node, snapshot.nodes),
};
if (fillWarning) {
resultPayload.warning = fillWarning;
}
sessionStore.recordAction(session, {
command: req.command,
positionals: req.positionals ?? [],
flags: req.flags ?? {},
result: resultPayload,
});
return { ok: true, data: resultPayload };
}
32 changes: 32 additions & 0 deletions src/daemon/handlers/interaction-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { CommandFlags } from '../../core/dispatch.ts';
import type { DaemonResponse } from '../types.ts';

const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
['snapshotDepth', '--depth'],
['snapshotScope', '--scope'],
['snapshotRaw', '--raw'],
];

export function refSnapshotFlagGuardResponse(
command: 'press' | 'fill' | 'get' | 'scrollintoview',
flags: CommandFlags | undefined,
): 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(', ')}.`,
},
};
}

export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
if (!flags) return [];
const unsupported: string[] = [];
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
if (flags[key] !== undefined) unsupported.push(label);
}
return unsupported;
}
Loading
Loading