mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
611858103e
* fix: type into focused iOS inputs without AX * fix: fill AX-hostile iOS text inputs * fix: keep scrolling containers from stealing taps * fix: stop agents after explicit task success * chore: format benchmark guidance * fix(ios): preserve fill semantics across fast paths * test: retire direct selector fill expectations * test: assert runtime selector fill evidence * fix(ios): preserve verified and Maestro fill paths * refactor(ios): isolate synthesized text entry * fix(client): preserve open diagnostic paths * fix(ios): expose structured text entry route * fix(packaging): strip text entry policy tests
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'vitest';
|
|
import { assertRpcOk } from '../provider-scenarios/assertions.ts';
|
|
import type { ProviderScenarioTranscript } from '../provider-scenarios/transcript.ts';
|
|
import { RUNNER_CONTINUE_NODES } from './fixtures.ts';
|
|
import {
|
|
CONTRACT_APP,
|
|
runnerLongPressEntry,
|
|
runnerSnapshotEntry,
|
|
runnerTapEntry,
|
|
runnerTypeEntry,
|
|
withIosContractDaemon,
|
|
} from './daemon-harness.ts';
|
|
|
|
type WireData = Record<string, unknown>;
|
|
|
|
const REF_KEYS = [
|
|
'message',
|
|
'ref',
|
|
'refLabel',
|
|
'referenceHeight',
|
|
'referenceWidth',
|
|
'resolution',
|
|
'selectorChain',
|
|
'targetKind',
|
|
'x',
|
|
'y',
|
|
] as const;
|
|
|
|
const SELECTOR_KEYS = [
|
|
'message',
|
|
'refLabel',
|
|
'referenceHeight',
|
|
'referenceWidth',
|
|
'resolution',
|
|
'selector',
|
|
'selectorChain',
|
|
'targetKind',
|
|
'x',
|
|
'y',
|
|
] as const;
|
|
|
|
const POINT_KEYS = ['message', 'targetKind', 'x', 'y'] as const;
|
|
|
|
// Single-match fixtures, so every envelope carries the exact/unique resolution shape (ADR 0012).
|
|
const EXACT_REF_RESOLUTION = { source: 'ref', phase: 'pre-action', kind: 'exact' } as const;
|
|
const UNIQUE_RUNTIME_RESOLUTION = {
|
|
source: 'runtime',
|
|
phase: 'pre-action',
|
|
kind: 'unique',
|
|
} as const;
|
|
const NOISY_DEFAULT_TAP_RESULT = {
|
|
count: 1,
|
|
currentUptimeMs: 1_418_719_052.9,
|
|
doubleTap: false,
|
|
gestureEndUptimeMs: 1_418_719_052.338875,
|
|
gestureStartUptimeMs: 1_418_718_741.4275,
|
|
holdMs: 0,
|
|
intervalMs: 0,
|
|
jitterPx: 0,
|
|
};
|
|
|
|
test('interaction response shape: press @ref uses the canonical ref envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry(NOISY_DEFAULT_TAP_RESULT)],
|
|
async (daemon, transcript) => {
|
|
assertRpcOk(await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true }));
|
|
|
|
const data = assertRpcOk(await daemon.callCommand('press', ['@e2']));
|
|
assertRunnerRequest(transcript, 'ios.runner.tap', tapRequest(200, 322));
|
|
|
|
assertCanonicalRef(data, {
|
|
message: 'Tapped @e2 (200, 322)',
|
|
x: 200,
|
|
y: 322,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: fill @ref uses the canonical ref envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTypeEntry({})],
|
|
async (daemon, transcript) => {
|
|
assertRpcOk(await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true }));
|
|
|
|
const data = assertRpcOk(await daemon.callCommand('fill', ['@e2', 'Hello']));
|
|
assertRunnerRequest(transcript, 'ios.runner.type', typeRequest(200, 322, 'Hello'));
|
|
|
|
assertCanonicalRef(data, {
|
|
message: 'Filled 5 chars',
|
|
x: 200,
|
|
y: 322,
|
|
extra: {
|
|
delayMs: 0,
|
|
text: 'Hello',
|
|
warning: 'fill target @e2 resolved to "Button", attempting fill anyway.',
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: longpress @ref uses the canonical ref envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerLongPressEntry({})],
|
|
async (daemon, transcript) => {
|
|
assertRpcOk(await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true }));
|
|
|
|
const data = assertRpcOk(await daemon.callCommand('longpress', ['@e2', '700']));
|
|
assertRunnerRequest(transcript, 'ios.runner.longPress', longPressRequest(200, 322, 700));
|
|
|
|
assertCanonicalRef(data, {
|
|
message: 'Long pressed @e2 (200, 322)',
|
|
x: 200,
|
|
y: 322,
|
|
extra: { durationMs: 700, gesture: 'longpress' },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: press selector uses the canonical selector envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({})],
|
|
async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('press', ['label=Continue']));
|
|
assertRunnerRequest(transcript, 'ios.runner.tap', tapRequest(200, 322));
|
|
|
|
assertCanonicalSelector(data, {
|
|
message: 'Tapped label=Continue (200, 322)',
|
|
x: 200,
|
|
y: 322,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: fill selector uses the canonical selector envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[
|
|
runnerSnapshotEntry(RUNNER_CONTINUE_NODES),
|
|
runnerTypeEntry({ ...NOISY_DEFAULT_TAP_RESULT, x: 200, y: 322 }),
|
|
],
|
|
async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('fill', ['label=Continue', 'Hello']));
|
|
assertRunnerRequest(transcript, 'ios.runner.type', typeRequest(200, 322, 'Hello'));
|
|
|
|
assertCanonicalSelector(data, {
|
|
message: 'Filled 5 chars',
|
|
x: 200,
|
|
y: 322,
|
|
extra: {
|
|
delayMs: 0,
|
|
text: 'Hello',
|
|
warning: 'fill target label=Continue resolved to "Button", attempting fill anyway.',
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: longpress selector uses the canonical selector envelope', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerLongPressEntry({})],
|
|
async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('longpress', ['label=Continue', '700']));
|
|
assertRunnerRequest(transcript, 'ios.runner.longPress', longPressRequest(200, 322, 700));
|
|
|
|
assertCanonicalSelector(data, {
|
|
message: 'Long pressed label=Continue (200, 322)',
|
|
x: 200,
|
|
y: 322,
|
|
extra: { durationMs: 700, gesture: 'longpress' },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: press point uses the canonical point envelope', async () => {
|
|
await withIosContractDaemon([runnerTapEntry({})], async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('press', ['100', '200']));
|
|
assertRunnerRequest(transcript, 'ios.runner.tap', tapRequest(100, 200));
|
|
|
|
assertCanonicalPoint(data, {
|
|
message: 'Tapped (100, 200)',
|
|
x: 100,
|
|
y: 200,
|
|
});
|
|
});
|
|
});
|
|
|
|
test('interaction response shape: fill point uses the canonical point envelope', async () => {
|
|
await withIosContractDaemon([runnerTypeEntry({})], async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('fill', ['100', '200', 'Hello']));
|
|
assertRunnerRequest(transcript, 'ios.runner.type', typeRequest(100, 200, 'Hello'));
|
|
|
|
assertCanonicalPoint(data, {
|
|
message: 'Filled 5 chars',
|
|
x: 100,
|
|
y: 200,
|
|
extra: { delayMs: 0, text: 'Hello' },
|
|
});
|
|
});
|
|
});
|
|
|
|
test('interaction response shape: type tolerates a zero-sized runner reference frame', async () => {
|
|
await withIosContractDaemon(
|
|
[runnerTypeEntry({ referenceWidth: 0, referenceHeight: 0 })],
|
|
async (daemon) => {
|
|
const data = assertRpcOk(await daemon.callCommand('type', ['Hello']));
|
|
|
|
// A zero-sized runner frame is unusable coordinate evidence. The daemon
|
|
// drops it instead of normalizing through zero or rejecting bare type.
|
|
assert.equal(data.referenceWidth, undefined);
|
|
assert.equal(data.referenceHeight, undefined);
|
|
assert.equal(data.text, 'Hello');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('interaction response shape: longpress point uses the canonical point envelope', async () => {
|
|
await withIosContractDaemon([runnerLongPressEntry({})], async (daemon, transcript) => {
|
|
const data = assertRpcOk(await daemon.callCommand('longpress', ['100', '200', '700']));
|
|
assertRunnerRequest(transcript, 'ios.runner.longPress', longPressRequest(100, 200, 700));
|
|
|
|
assertCanonicalPoint(data, {
|
|
message: 'Long pressed (100, 200)',
|
|
x: 100,
|
|
y: 200,
|
|
extra: { durationMs: 700, gesture: 'longpress' },
|
|
});
|
|
});
|
|
});
|
|
|
|
function assertCanonicalRef(
|
|
data: WireData,
|
|
expected: { message: string; x: number; y: number; extra?: WireData },
|
|
): void {
|
|
assertExactKeys(data, [...REF_KEYS, ...Object.keys(expected.extra ?? {})]);
|
|
assert.deepEqual(
|
|
pick(data, [
|
|
'message',
|
|
'ref',
|
|
'refLabel',
|
|
'referenceHeight',
|
|
'referenceWidth',
|
|
'resolution',
|
|
'targetKind',
|
|
'x',
|
|
'y',
|
|
...Object.keys(expected.extra ?? {}),
|
|
]),
|
|
{
|
|
message: expected.message,
|
|
ref: 'e2',
|
|
refLabel: 'Continue',
|
|
referenceHeight: 800,
|
|
referenceWidth: 400,
|
|
resolution: EXACT_REF_RESOLUTION,
|
|
targetKind: 'ref',
|
|
x: expected.x,
|
|
y: expected.y,
|
|
...(expected.extra ?? {}),
|
|
},
|
|
);
|
|
assertSelectorChain(data);
|
|
assertNoWireNoise(data);
|
|
}
|
|
|
|
function assertCanonicalSelector(
|
|
data: WireData,
|
|
expected: { message: string; x: number; y: number; extra?: WireData },
|
|
): void {
|
|
assertExactKeys(data, [...SELECTOR_KEYS, ...Object.keys(expected.extra ?? {})]);
|
|
assert.deepEqual(
|
|
pick(data, [
|
|
'message',
|
|
'refLabel',
|
|
'referenceHeight',
|
|
'referenceWidth',
|
|
'resolution',
|
|
'selector',
|
|
'targetKind',
|
|
'x',
|
|
'y',
|
|
...Object.keys(expected.extra ?? {}),
|
|
]),
|
|
{
|
|
message: expected.message,
|
|
refLabel: 'Continue',
|
|
referenceHeight: 800,
|
|
referenceWidth: 400,
|
|
resolution: UNIQUE_RUNTIME_RESOLUTION,
|
|
selector: 'label=Continue',
|
|
targetKind: 'selector',
|
|
x: expected.x,
|
|
y: expected.y,
|
|
...(expected.extra ?? {}),
|
|
},
|
|
);
|
|
assertSelectorChain(data);
|
|
assertNoWireNoise(data);
|
|
}
|
|
|
|
function assertCanonicalPoint(
|
|
data: WireData,
|
|
expected: { message: string; x: number; y: number; extra?: WireData },
|
|
): void {
|
|
assertExactKeys(data, [...POINT_KEYS, ...Object.keys(expected.extra ?? {})]);
|
|
assert.deepEqual(
|
|
pick(data, ['message', 'targetKind', 'x', 'y', ...Object.keys(expected.extra ?? {})]),
|
|
{
|
|
message: expected.message,
|
|
targetKind: 'point',
|
|
x: expected.x,
|
|
y: expected.y,
|
|
...(expected.extra ?? {}),
|
|
},
|
|
);
|
|
assertNoWireNoise(data);
|
|
}
|
|
|
|
function assertSelectorChain(data: WireData): void {
|
|
assert.ok(Array.isArray(data.selectorChain), 'selectorChain must be present');
|
|
assert.ok(data.selectorChain.length > 0, 'selectorChain must be non-empty');
|
|
}
|
|
|
|
function assertExactKeys(data: WireData, expectedKeys: readonly string[]): void {
|
|
assert.deepEqual(Object.keys(data).sort(), [...expectedKeys].sort());
|
|
}
|
|
|
|
function assertNoWireNoise(data: WireData): void {
|
|
for (const key of [
|
|
'count',
|
|
'currentUptimeMs',
|
|
'doubleTap',
|
|
'gestureEndUptimeMs',
|
|
'gestureStartUptimeMs',
|
|
'holdMs',
|
|
'intervalMs',
|
|
'jitterPx',
|
|
'sequenceResults',
|
|
]) {
|
|
assert.equal(data[key], undefined, `${key} should not be in the wire response`);
|
|
}
|
|
}
|
|
|
|
function assertRunnerRequest(
|
|
transcript: ProviderScenarioTranscript,
|
|
command: string,
|
|
expected: WireData,
|
|
): void {
|
|
const call = [...transcript.calls].reverse().find((entry) => entry.command === command);
|
|
assert.ok(call, `Expected transcript to include ${command}`);
|
|
assert.deepEqual(stripUndefined(call.request as WireData), expected);
|
|
}
|
|
|
|
function pick(data: WireData, keys: readonly string[]): WireData {
|
|
return Object.fromEntries(keys.map((key) => [key, data[key]]));
|
|
}
|
|
|
|
function stripUndefined(data: WireData): WireData {
|
|
return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
|
|
}
|
|
|
|
function tapRequest(x: number, y: number): WireData {
|
|
return { command: 'tap', x, y, synthesized: true, appBundleId: CONTRACT_APP };
|
|
}
|
|
|
|
function typeRequest(x: number, y: number, text: string): WireData {
|
|
return {
|
|
command: 'type',
|
|
x,
|
|
y,
|
|
text,
|
|
delayMs: 0,
|
|
textEntryMode: 'replace',
|
|
appBundleId: CONTRACT_APP,
|
|
};
|
|
}
|
|
|
|
function longPressRequest(x: number, y: number, durationMs: number): WireData {
|
|
return { command: 'longPress', x, y, durationMs, appBundleId: CONTRACT_APP };
|
|
}
|