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
1 change: 1 addition & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ function summarizeProviderScenarioFlagCoverage(files) {
['batchMaxSteps', 'batch max-step guard', ['maxSteps']],
['findFirst', 'find first disambiguation'],
['findLast', 'find last disambiguation'],
['verify', 'post-action evidence capture on press/click/fill'],
];
const sources = files.map((file) => fs.readFileSync(file, 'utf8')).join('\n');
return flagTargets.map(([key, reason, aliases = []]) => {
Expand Down
9 changes: 9 additions & 0 deletions src/cli/parser/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export type CliFlags = CloudProviderProfileFields &
jitterPx?: number;
pixels?: number;
doubleTap?: boolean;
verify?: boolean;
clickButton?: ClickButton;
backMode?: BackMode;
pauseMs?: number;
Expand Down Expand Up @@ -839,6 +840,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--double-tap',
usageDescription: 'Use double-tap gesture per press iteration',
},
{
key: 'verify',
names: ['--verify'],
type: 'boolean',
usageLabel: '--verify',
usageDescription:
'Capture cheap post-action evidence (AX digest, node counts, changedFromBefore) instead of a follow-up snapshot',
},
{
key: 'clickButton',
names: ['--button'],
Expand Down
1 change: 1 addition & 0 deletions src/client/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
jitterPx: options.jitterPx,
pixels: options.pixels,
doubleTap: options.doubleTap,
verify: options.verify,
clickButton: options.clickButton,
pauseMs: options.pauseMs,
pattern: options.pattern,
Expand Down
12 changes: 11 additions & 1 deletion src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,12 +613,20 @@ export type ClickOptions = DeviceCommandBaseOptions &
InteractionTarget &
RepeatedPressOptions & {
button?: ClickButton;
/**
* Opt-in (#1047): return cheap post-action evidence (AX digest, node counts,
* changedFromBefore) in the response instead of requiring a follow-up
* snapshot to confirm the action had an effect.
*/
verify?: boolean;
};

export type PressOptions = DeviceCommandBaseOptions &
SelectorSnapshotCommandOptions &
InteractionTarget &
RepeatedPressOptions;
RepeatedPressOptions & {
verify?: boolean;
};

export type LongPressOptions = DeviceCommandBaseOptions &
SelectorSnapshotCommandOptions &
Expand Down Expand Up @@ -671,6 +679,7 @@ export type FillOptions = DeviceCommandBaseOptions &
InteractionTarget & {
text: string;
delayMs?: number;
verify?: boolean;
};

export type ScrollOptions = DeviceCommandBaseOptions & {
Expand Down Expand Up @@ -897,6 +906,7 @@ type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
jitterPx?: number;
pixels?: number;
doubleTap?: boolean;
verify?: boolean;
clickButton?: ClickButton;
pauseMs?: number;
pattern?: SwipePattern;
Expand Down
9 changes: 6 additions & 3 deletions src/commands/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ const interactionCliSchemas = {
usageOverride: 'click <x y|@ref|selector>',
positionalArgs: ['target'],
allowsExtraPositionals: true,
allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS],
allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', 'verify', ...SELECTOR_SNAPSHOT_FLAGS],
},
press: {
usageOverride: 'press <x y|@ref|selector>',
helpDescription:
'Short press a semantic UI target by ref, selector, or point. For native context menus or hold gestures, use longpress <target> <durationMs> instead of press --hold-ms.',
positionalArgs: ['targetOrX', 'y?'],
allowsExtraPositionals: true,
allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS],
allowedFlags: [...REPEATED_TOUCH_FLAGS, 'verify', ...SELECTOR_SNAPSHOT_FLAGS],
},
longpress: {
usageOverride: 'longpress <x y|@ref|selector> [durationMs]',
Expand Down Expand Up @@ -114,7 +114,7 @@ const interactionCliSchemas = {
usageOverride: 'fill <x> <y> <text> | fill <@ref|selector> <text>',
positionalArgs: ['targetOrX', 'yOrText', 'text?'],
allowsExtraPositionals: true,
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'],
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs', 'verify'],
},
scroll: {
usageOverride: 'scroll <direction|top|bottom> [amount] [--pixels <n>] [--duration-ms <ms>]',
Expand Down Expand Up @@ -342,6 +342,7 @@ function toClickOptions(input: ClickInput): ClickOptions {
...toSelectorSnapshotOptions(input),
...toRepeatedOptions(input),
button: input.button,
verify: input.verify,
};
}

Expand All @@ -351,6 +352,7 @@ function toPressOptions(input: PressInput): PressOptions {
...toClientInteractionTarget(input.target),
...toSelectorSnapshotOptions(input),
...toRepeatedOptions(input),
verify: input.verify,
};
}

Expand All @@ -361,6 +363,7 @@ function toFillOptions(input: FillInput): FillOptions {
...toSelectorSnapshotOptions(input),
text: input.text,
delayMs: input.delayMs,
verify: input.verify,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/commands/interaction/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ export const interactionCliReaders = {
...repeatedInputFromFlags(flags),
target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)),
button: flags.clickButton,
verify: flags.verify,
}),
press: (positionals, flags) => ({
...commonInputFromFlags(flags),
...selectorSnapshotInputFromFlags(flags),
...repeatedInputFromFlags(flags),
target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)),
verify: flags.verify,
}),
longpress: (positionals, flags) => {
const decoded = readLongPressTargetFromPositionals(positionals);
Expand Down Expand Up @@ -80,6 +82,7 @@ export const interactionCliReaders = {
target: targetInputFromClientTarget(decoded.target),
text: decoded.text,
delayMs: flags.delayMs,
verify: flags.verify,
};
},
scroll: (positionals, flags) => ({
Expand Down
8 changes: 8 additions & 0 deletions src/commands/interaction/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,32 @@ const interactionCommandDescriptions = {

type InteractionCommandName = keyof typeof interactionCommandDescriptions;

const verifyField = () =>
booleanField(
'Capture cheap post-action evidence (AX digest, node counts, changedFromBefore) instead of a follow-up snapshot.',
);

const clickFields = {
target: requiredField(interactionTargetField()),
button: enumField(CLICK_BUTTONS, 'Pointer button for platforms that support mouse buttons.'),
...selectorSnapshotFields(),
...repeatedFields(),
verify: verifyField(),
};

const pressFields = {
target: requiredField(interactionTargetField()),
...selectorSnapshotFields(),
...repeatedFields(),
verify: verifyField(),
};

const fillFields = {
target: requiredField(interactionTargetField()),
text: requiredField(stringField('Text to enter into the target.')),
delayMs: integerField('Delay between typed characters.', { min: 0 }),
...selectorSnapshotFields(),
verify: verifyField(),
};

const longPressFields = {
Expand Down
171 changes: 171 additions & 0 deletions src/commands/interaction/runtime/interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,56 @@ test('runtime click taps an explicit point without requiring a snapshot', async
assert.deepEqual(result, { kind: 'point', point: { x: 10, y: 20 } });
});

test('runtime click with verify captures a baseline for point targets and reports evidence', async () => {
let captureCount = 0;
const device = createInteractionDevice(selectorSnapshot(), {
captureSnapshot: async () => {
captureCount += 1;
if (captureCount === 1) return { snapshot: selectorSnapshot() };
return { snapshot: makeSnapshotState([]) };
},
tap: async () => ({ ok: true }),
});

const result = await device.interactions.click(
{ kind: 'point', x: 10, y: 20 },
{ session: 'default', verify: true },
);

assert.equal(result.kind, 'point');
assert.ok(result.evidence);
assert.equal(result.evidence?.changedFromBefore, true);
assert.equal(result.evidence?.nodeCount, 0);
assert.equal(captureCount, 2);
});

test('runtime click with verify skips the native ref fast path so evidence can be captured', async () => {
const calls: string[] = [];
let captureCount = 0;
const device = createInteractionDevice(selectorSnapshot(), {
platform: 'web',
captureSnapshot: async () => {
captureCount += 1;
return { snapshot: selectorSnapshot() };
},
tapTarget: async (_context, target) => {
calls.push(target.ref);
return { ref: target.ref.replace(/^@/, '') };
},
tap: async () => ({ ok: true }),
});

const result = await device.interactions.click(ref('@e1'), {
session: 'default',
verify: true,
});

assert.deepEqual(calls, []);
assert.equal(result.kind, 'ref');
assert.ok(result.evidence);
assert.ok(captureCount >= 1);
});

test('runtime click uses backend ref primitive without resolving snapshot geometry', async () => {
const calls: string[] = [];
const device = createInteractionDevice(selectorSnapshot(), {
Expand Down Expand Up @@ -162,6 +212,127 @@ test('runtime selector interactions fall back to a full snapshot when interactiv
]);
});

test('runtime press without verify omits evidence entirely', async () => {
const device = createInteractionDevice(selectorSnapshot(), {
tap: async () => ({ ok: true }),
});

const result = await device.interactions.press(selector('label=Continue'), {
session: 'default',
});

assert.equal('evidence' in result, false);
});

test('runtime press with verify reports unchanged evidence when the post-action capture matches', async () => {
const device = createInteractionDevice(selectorSnapshot(), {
tap: async () => ({ ok: true }),
});

const result = await device.interactions.press(selector('label=Continue'), {
session: 'default',
verify: true,
});

assert.equal(result.kind, 'selector');
assert.ok(result.evidence);
assert.equal(result.evidence?.changedFromBefore, false);
assert.equal(result.evidence?.nodeCount, 1);
assert.equal(result.evidence?.interactiveNodeCount, 1);
assert.equal(typeof result.evidence?.digest, 'string');
assert.ok(result.evidence?.digest.startsWith('ax1:'));
});

test('runtime press with verify reports changedFromBefore true when the post-action capture differs', async () => {
let captureCount = 0;
const device = createInteractionDevice(selectorSnapshot(), {
captureSnapshot: async () => {
captureCount += 1;
if (captureCount === 1) return { snapshot: selectorSnapshot() };
return {
snapshot: makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Continue',
value: 'Continue',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
{
index: 1,
depth: 0,
type: 'Text',
label: 'Loading…',
rect: { x: 10, y: 80, width: 100, height: 20 },
hittable: true,
},
]),
};
},
tap: async () => ({ ok: true }),
});

const result = await device.interactions.press(selector('label=Continue'), {
session: 'default',
verify: true,
});

assert.equal(result.kind, 'selector');
assert.ok(result.evidence);
assert.equal(result.evidence?.changedFromBefore, true);
assert.equal(result.evidence?.nodeCount, 2);
});

test('runtime fill without verify omits evidence entirely', async () => {
const device = createInteractionDevice(fillableSnapshot(), {
fill: async () => ({ ok: true }),
});

const result = await device.interactions.fill(selector('label=Email'), 'hi', {
session: 'default',
});

assert.equal('evidence' in result, false);
});

test('runtime fill with verify reports evidence and detects a changed post-action capture', async () => {
let captureCount = 0;
const device = createInteractionDevice(fillableSnapshot(), {
captureSnapshot: async () => {
captureCount += 1;
if (captureCount === 1) return { snapshot: fillableSnapshot() };
return {
snapshot: makeSnapshotState([
{
index: 0,
depth: 0,
type: 'XCUIElementTypeTextField',
label: 'Email',
value: 'hi',
rect: { x: 20, y: 10, width: 60, height: 40 },
hittable: true,
},
]),
};
},
fill: async () => ({ ok: true }),
});

const result = await device.interactions.fill(selector('label=Email'), 'hi', {
session: 'default',
verify: true,
});

assert.equal(result.kind, 'selector');
assert.ok(result.evidence);
// Digest is over (type, label, identifier) only, so a value-only change does
// not flip the digest — this is intentional (see ax-digest.ts docs).
assert.equal(result.evidence?.changedFromBefore, false);
assert.equal(result.evidence?.nodeCount, 1);
});

test('runtime click keeps distinct tab button centers when iOS reports the tab bar as hittable', async () => {
const calls: Point[] = [];
const device = createInteractionDevice(iosTabBarSnapshot(), {
Expand Down
Loading
Loading