@farmslot/adapter-rn 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/farmslot-adapter-rn.mjs +10 -0
- package/dist/agent-device-ui-transport.d.ts +88 -0
- package/dist/agent-device-ui-transport.d.ts.map +1 -0
- package/dist/agent-device-ui-transport.js +1148 -0
- package/dist/agent-device-ui-transport.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +169 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +125 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +5 -0
- package/dist/json.js.map +1 -0
- package/dist/metro-recipe-bridge-transport.d.ts +12 -0
- package/dist/metro-recipe-bridge-transport.d.ts.map +1 -0
- package/dist/metro-recipe-bridge-transport.js +63 -0
- package/dist/metro-recipe-bridge-transport.js.map +1 -0
- package/dist/redaction.d.ts +3 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +57 -0
- package/dist/redaction.js.map +1 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +270 -0
- package/dist/runner.js.map +1 -0
- package/dist/scaffold.d.ts +14 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +82 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/simctl-video-recorder.d.ts +6 -0
- package/dist/simctl-video-recorder.d.ts.map +1 -0
- package/dist/simctl-video-recorder.js +160 -0
- package/dist/simctl-video-recorder.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +81 -0
- package/templates/scripts/agentic/recipe/README.md +15 -0
- package/templates/scripts/agentic/recipe/action-manifest.json +547 -0
- package/templates/scripts/agentic/recipe/action-manifest.with-bridge.json +599 -0
- package/templates/scripts/agentic/recipe/recipes/expo.config.recipe.json +37 -0
- package/templates/scripts/agentic/validate-recipe.sh +64 -0
- package/templates/src/farmslot/RecipeBridgeProvider.tsx +127 -0
- package/templates/src/farmslot/RecipeHud.tsx +138 -0
- package/templates/src/farmslot/index.ts +11 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { PNG } from 'pngjs';
|
|
6
|
+
import { gestureDurationMs, gesturePhase, gesturePoints, gestureSegmentDuration, gestureTarget, } from '@farmslot/recipe-runner';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export const NATIVE_UI_ACTIONS = [
|
|
9
|
+
'ui.press',
|
|
10
|
+
'ui.key_press',
|
|
11
|
+
'ui.set_input',
|
|
12
|
+
'ui.scroll',
|
|
13
|
+
'ui.swipe',
|
|
14
|
+
'ui.pan',
|
|
15
|
+
'ui.drag',
|
|
16
|
+
'ui.long_press',
|
|
17
|
+
'ui.wait_for',
|
|
18
|
+
'ui.screenshot',
|
|
19
|
+
'ui.capture_surface',
|
|
20
|
+
];
|
|
21
|
+
export function createAgentDeviceUiTransport(options) {
|
|
22
|
+
let clientPromise = options.client ? Promise.resolve(options.client) : undefined;
|
|
23
|
+
const selection = agentDeviceSelection(options.platform, options.device);
|
|
24
|
+
let opened = false;
|
|
25
|
+
const gestureCommandRunner = options.gestureCommandRunner ?? defaultGestureCommandRunner();
|
|
26
|
+
let gestureToolPromise;
|
|
27
|
+
let gestureDevicePromise;
|
|
28
|
+
function gestureTool() {
|
|
29
|
+
gestureToolPromise ??= resolveGestureTool(options.platform === 'ios' ? 'idb' : 'adb', options.platform === 'ios' ? options.idbPath : options.adbPath, gestureCommandRunner).catch((error) => {
|
|
30
|
+
gestureToolPromise = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return gestureToolPromise;
|
|
34
|
+
}
|
|
35
|
+
function gestureDevice() {
|
|
36
|
+
gestureDevicePromise ??= (options.platform === 'ios'
|
|
37
|
+
? gestureTool().then((tool) => resolveIosGestureDevice(options.device, tool, gestureCommandRunner))
|
|
38
|
+
: Promise.resolve(options.device)).catch((error) => {
|
|
39
|
+
gestureDevicePromise = undefined;
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
return gestureDevicePromise;
|
|
43
|
+
}
|
|
44
|
+
async function resolveClient() {
|
|
45
|
+
if (!clientPromise) {
|
|
46
|
+
assertAgentDeviceNodeVersion(process.versions.node);
|
|
47
|
+
clientPromise = import('agent-device')
|
|
48
|
+
.then(({ createAgentDeviceClient }) => createAgentDeviceClient({
|
|
49
|
+
session: options.session,
|
|
50
|
+
stateDir: options.stateDir,
|
|
51
|
+
lockPolicy: 'reject',
|
|
52
|
+
lockPlatform: options.platform,
|
|
53
|
+
}))
|
|
54
|
+
.catch((error) => {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
throw new Error(`Native recipe actions require the optional agent-device provider: ${message}`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return clientPromise;
|
|
60
|
+
}
|
|
61
|
+
async function ensureOpen() {
|
|
62
|
+
const client = await resolveClient();
|
|
63
|
+
if (opened)
|
|
64
|
+
return client;
|
|
65
|
+
await client.apps.open({
|
|
66
|
+
...selection,
|
|
67
|
+
app: options.app,
|
|
68
|
+
session: options.session,
|
|
69
|
+
relaunch: false,
|
|
70
|
+
noRecord: true,
|
|
71
|
+
});
|
|
72
|
+
opened = true;
|
|
73
|
+
return client;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
async open() {
|
|
77
|
+
await ensureOpen();
|
|
78
|
+
},
|
|
79
|
+
async execute(action, node, context) {
|
|
80
|
+
const client = await ensureOpen();
|
|
81
|
+
switch (action) {
|
|
82
|
+
case 'ui.press':
|
|
83
|
+
return requireSettledInteraction('ui.press', await client.interactions.press({
|
|
84
|
+
...selection,
|
|
85
|
+
session: options.session,
|
|
86
|
+
selector: selectorFromNode(node, 'ui.press'),
|
|
87
|
+
settle: node.settle !== false,
|
|
88
|
+
verify: true,
|
|
89
|
+
responseLevel: 'digest',
|
|
90
|
+
timeoutMs: positiveNumber(node.timeout_ms) ?? 10_000,
|
|
91
|
+
}), node.settle !== false);
|
|
92
|
+
case 'ui.key_press': {
|
|
93
|
+
const keyAction = nativeKeyboardAction(node.key);
|
|
94
|
+
const result = keyAction === 'back'
|
|
95
|
+
? await pressNativeBack(client, selection, options.session)
|
|
96
|
+
: requireNativeKeyboardEffect(keyAction, await client.command.keyboard({
|
|
97
|
+
...selection,
|
|
98
|
+
session: options.session,
|
|
99
|
+
action: keyAction,
|
|
100
|
+
responseLevel: 'digest',
|
|
101
|
+
}));
|
|
102
|
+
if (node.settle === false)
|
|
103
|
+
return result;
|
|
104
|
+
return {
|
|
105
|
+
...result,
|
|
106
|
+
...(await awaitNativeStability(client, options.session, selection, 'ui.key_press', positiveNumber(node.timeout_ms) ?? 10_000)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case 'ui.set_input':
|
|
110
|
+
return sanitizeFillResult(await replaceNativeInput({
|
|
111
|
+
client,
|
|
112
|
+
selection,
|
|
113
|
+
session: options.session,
|
|
114
|
+
node,
|
|
115
|
+
platform: options.platform,
|
|
116
|
+
commandRunner: gestureCommandRunner,
|
|
117
|
+
tool: gestureTool,
|
|
118
|
+
device: gestureDevice,
|
|
119
|
+
}));
|
|
120
|
+
case 'ui.scroll': {
|
|
121
|
+
if (node.offset_x !== undefined || node.offset_y !== undefined) {
|
|
122
|
+
throw new Error('ui.scroll offset_x/offset_y (absolute) is not supported by Agent Device; use direction with pixels or amount (relative).');
|
|
123
|
+
}
|
|
124
|
+
const delta = relativeScrollDelta(node);
|
|
125
|
+
const result = await client.interactions.scroll({
|
|
126
|
+
...selection,
|
|
127
|
+
session: options.session,
|
|
128
|
+
direction: delta?.direction ?? scrollDirection(node),
|
|
129
|
+
amount: positiveNumber(node.amount),
|
|
130
|
+
pixels: delta?.pixels ?? positiveNumber(node.pixels),
|
|
131
|
+
durationMs: positiveNumber(node.duration_ms),
|
|
132
|
+
responseLevel: 'digest',
|
|
133
|
+
});
|
|
134
|
+
if (node.settle === false)
|
|
135
|
+
return result;
|
|
136
|
+
return {
|
|
137
|
+
...result,
|
|
138
|
+
...(await awaitNativeStability(client, options.session, selection, 'ui.scroll', positiveNumber(node.timeout_ms) ?? 10_000)),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case 'ui.swipe':
|
|
142
|
+
case 'ui.pan':
|
|
143
|
+
case 'ui.drag':
|
|
144
|
+
case 'ui.long_press':
|
|
145
|
+
return executeNativeGesture(client, options.session, selection, action, node, gestureCommandRunner, await gestureTool(), await gestureDevice());
|
|
146
|
+
case 'ui.wait_for': {
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
const result = await waitForNode(client, options.session, selection, node);
|
|
149
|
+
if (node.settle === false)
|
|
150
|
+
return result;
|
|
151
|
+
// timeout_ms budgets the whole node: stability only gets what the wait left over.
|
|
152
|
+
return {
|
|
153
|
+
...result,
|
|
154
|
+
...(await awaitNativeStability(client, options.session, selection, 'ui.wait_for', Math.max(1, (positiveNumber(node.timeout_ms) ?? 10_000) - (Date.now() - startedAt)))),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case 'ui.screenshot':
|
|
158
|
+
return captureScreenshot(client, options.session, node, context);
|
|
159
|
+
case 'ui.capture_surface':
|
|
160
|
+
return captureSurface(client, options.session, selection, node, context);
|
|
161
|
+
default:
|
|
162
|
+
throw new Error(`${action} is not supported by the Agent Device UI transport.`);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
async observe(refs) {
|
|
166
|
+
const client = await ensureOpen();
|
|
167
|
+
const snapshot = await client.capture.snapshot({
|
|
168
|
+
...selection,
|
|
169
|
+
session: options.session,
|
|
170
|
+
interactiveOnly: true,
|
|
171
|
+
forceFull: true,
|
|
172
|
+
});
|
|
173
|
+
return observationsFromSnapshot(refs, snapshot);
|
|
174
|
+
},
|
|
175
|
+
async close() {
|
|
176
|
+
if (!opened)
|
|
177
|
+
return;
|
|
178
|
+
const client = await resolveClient();
|
|
179
|
+
await client.sessions.close({ session: options.session, shutdown: false });
|
|
180
|
+
opened = false;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function nativeKeyboardAction(value) {
|
|
185
|
+
const key = requiredString(value, 'ui.key_press.key').toLowerCase();
|
|
186
|
+
if (key === 'back')
|
|
187
|
+
return 'back';
|
|
188
|
+
if (key === 'escape')
|
|
189
|
+
return 'dismiss';
|
|
190
|
+
if (key === 'enter' || key === 'return')
|
|
191
|
+
return 'enter';
|
|
192
|
+
throw new Error(`ui.key_press key ${JSON.stringify(value)} is not supported by the native device transport. Next: use Back, Escape, Enter, or Return`);
|
|
193
|
+
}
|
|
194
|
+
function requireNativeKeyboardEffect(action, result) {
|
|
195
|
+
if (!result || typeof result !== 'object') {
|
|
196
|
+
throw new Error(`ui.key_press ${action} returned no observable keyboard result.`);
|
|
197
|
+
}
|
|
198
|
+
const outcome = result;
|
|
199
|
+
if (outcome.wasVisible === false) {
|
|
200
|
+
throw new Error(`ui.key_press ${action} could not affect the keyboard: it was not visible.`);
|
|
201
|
+
}
|
|
202
|
+
if (action === 'dismiss' && outcome.dismissed !== true) {
|
|
203
|
+
if (outcome.wasVisible === true) {
|
|
204
|
+
throw new Error('ui.key_press dismiss could not dismiss the visible keyboard.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (outcome.wasVisible === undefined) {
|
|
208
|
+
return {
|
|
209
|
+
...outcome,
|
|
210
|
+
settlementWarning: `ui.key_press ${action} completed without provider keyboard-visibility evidence.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
async function pressNativeBack(client, selection, session) {
|
|
216
|
+
if (!client.command.back) {
|
|
217
|
+
throw new Error('ui.key_press Back requires an agent-device client with native back-navigation support.');
|
|
218
|
+
}
|
|
219
|
+
return client.command.back({
|
|
220
|
+
...selection,
|
|
221
|
+
session,
|
|
222
|
+
responseLevel: 'digest',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async function replaceNativeInput({ client, selection, session, node, platform, commandRunner, tool, device, }) {
|
|
226
|
+
const text = requiredString(node.value ?? node.text, 'ui.set_input.value');
|
|
227
|
+
const testId = optionalString(node.test_id ?? node.testID);
|
|
228
|
+
if (platform === 'android' && !testId) {
|
|
229
|
+
throw new Error('Android ui.set_input requires test_id so replacement can be verified from the native snapshot.');
|
|
230
|
+
}
|
|
231
|
+
const selector = selectorFromNode(node, 'ui.set_input');
|
|
232
|
+
const fill = () => client.interactions.fill({
|
|
233
|
+
...selection,
|
|
234
|
+
session,
|
|
235
|
+
selector,
|
|
236
|
+
text,
|
|
237
|
+
settle: node.settle !== false,
|
|
238
|
+
verify: true,
|
|
239
|
+
responseLevel: 'digest',
|
|
240
|
+
timeoutMs: positiveNumber(node.timeout_ms) ?? 10_000,
|
|
241
|
+
});
|
|
242
|
+
let result = await fill();
|
|
243
|
+
if (platform !== 'android') {
|
|
244
|
+
return requireSettledInteraction('ui.set_input', result, node.settle !== false);
|
|
245
|
+
}
|
|
246
|
+
if (!testId)
|
|
247
|
+
throw new Error('Android ui.set_input requires test_id.');
|
|
248
|
+
let observed = await nativeInputValue(client, selection, session, testId);
|
|
249
|
+
if (!observed.verifiable) {
|
|
250
|
+
throw new Error('ui.set_input could not verify the Android field after replacement.');
|
|
251
|
+
}
|
|
252
|
+
if (observed.value === text ||
|
|
253
|
+
(observed.masked &&
|
|
254
|
+
interactionChanged(result) &&
|
|
255
|
+
Array.from(observed.value).length === Array.from(text).length)) {
|
|
256
|
+
return requireSettledInteraction('ui.set_input', result, node.settle !== false);
|
|
257
|
+
}
|
|
258
|
+
await client.interactions.press({
|
|
259
|
+
...selection,
|
|
260
|
+
session,
|
|
261
|
+
selector,
|
|
262
|
+
settle: false,
|
|
263
|
+
verify: false,
|
|
264
|
+
responseLevel: 'digest',
|
|
265
|
+
timeoutMs: positiveNumber(node.timeout_ms) ?? 10_000,
|
|
266
|
+
});
|
|
267
|
+
const adb = await tool();
|
|
268
|
+
const serial = await device();
|
|
269
|
+
await commandRunner.execFile(adb, ['-s', serial, 'shell', 'input', 'keyevent', '123'], {
|
|
270
|
+
timeoutMs: 10_000,
|
|
271
|
+
});
|
|
272
|
+
await deleteAndroidFieldCharacters(commandRunner, adb, serial, Array.from(observed.value).length);
|
|
273
|
+
let cleared = await nativeInputValue(client, selection, session, testId);
|
|
274
|
+
if (!cleared.verifiable) {
|
|
275
|
+
throw new Error('ui.set_input could not clear the Android field before replacement.');
|
|
276
|
+
}
|
|
277
|
+
if (cleared.value.length > 0) {
|
|
278
|
+
const firstClearedValue = cleared.value;
|
|
279
|
+
await deleteAndroidFieldCharacters(commandRunner, adb, serial, Array.from(firstClearedValue).length);
|
|
280
|
+
cleared = await nativeInputValue(client, selection, session, testId);
|
|
281
|
+
const stablePlaceholder = cleared.verifiable && !cleared.masked && cleared.value === firstClearedValue;
|
|
282
|
+
if (!cleared.verifiable || (cleared.value.length > 0 && !stablePlaceholder)) {
|
|
283
|
+
throw new Error('ui.set_input could not clear the Android field before replacement.');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
result = await fill();
|
|
287
|
+
observed = await nativeInputValue(client, selection, session, testId);
|
|
288
|
+
if (!observed.verifiable ||
|
|
289
|
+
(observed.masked
|
|
290
|
+
? Array.from(observed.value).length !== Array.from(text).length
|
|
291
|
+
: observed.value !== text)) {
|
|
292
|
+
throw new Error('ui.set_input could not verify the requested Android replacement value.');
|
|
293
|
+
}
|
|
294
|
+
return requireSettledInteraction('ui.set_input', result, node.settle !== false);
|
|
295
|
+
}
|
|
296
|
+
async function deleteAndroidFieldCharacters(commandRunner, adb, serial, characterCount) {
|
|
297
|
+
for (let offset = 0; offset < characterCount; offset += 24) {
|
|
298
|
+
const count = Math.min(24, characterCount - offset);
|
|
299
|
+
await commandRunner.execFile(adb, ['-s', serial, 'shell', 'input', 'keyevent', ...Array.from({ length: count }, () => '67')], { timeoutMs: Math.max(10_000, count * 150 + 2_000) });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function interactionChanged(result) {
|
|
303
|
+
return result.evidence?.changedFromBefore === true;
|
|
304
|
+
}
|
|
305
|
+
async function nativeInputValue(client, selection, session, testId) {
|
|
306
|
+
const snapshot = await client.capture.snapshot({
|
|
307
|
+
...selection,
|
|
308
|
+
session,
|
|
309
|
+
interactiveOnly: false,
|
|
310
|
+
forceFull: true,
|
|
311
|
+
});
|
|
312
|
+
const target = snapshot.nodes.find((candidate) => candidate.identifier === testId);
|
|
313
|
+
const value = target?.value;
|
|
314
|
+
if (typeof value !== 'string') {
|
|
315
|
+
return { value: '', verifiable: false, masked: false };
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
value,
|
|
319
|
+
verifiable: true,
|
|
320
|
+
masked: value.length > 0 &&
|
|
321
|
+
Array.from(value).every((character) => character === '\u2022' || character === '*' || character === '\u25cf'),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function executeNativeGesture(client, session, selection, action, node, commandRunner, gestureTool, gestureDevice) {
|
|
325
|
+
const start = await resolveNativeGesturePoint(client, session, selection, gestureTarget(node, action));
|
|
326
|
+
const durationMs = gestureDurationMs(node, action);
|
|
327
|
+
const points = gesturePoints(action, node, start).map(roundPoint);
|
|
328
|
+
if ((action === 'ui.pan' || action === 'ui.drag') && points.length > 2) {
|
|
329
|
+
throw new Error(`${selection.platform} ${action} cannot stream a multi-point path. Next: use one path point or delta.`);
|
|
330
|
+
}
|
|
331
|
+
const segmentDurationMs = gestureSegmentDuration(durationMs, points);
|
|
332
|
+
const phases = [];
|
|
333
|
+
const gestureSelection = { platform: selection.platform, device: gestureDevice };
|
|
334
|
+
let segments = 0;
|
|
335
|
+
const startedAtMs = Date.now();
|
|
336
|
+
phases.push(gesturePhase('start', start, startedAtMs));
|
|
337
|
+
if (action === 'ui.long_press') {
|
|
338
|
+
await runNativeLongPress(commandRunner, gestureTool, gestureSelection, start, durationMs);
|
|
339
|
+
segments = 1;
|
|
340
|
+
phases.push(gesturePhase('move', start, startedAtMs));
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
344
|
+
const from = points[index - 1];
|
|
345
|
+
const to = points[index];
|
|
346
|
+
await runNativeSwipe(commandRunner, gestureTool, gestureSelection, from, to, segmentDurationMs);
|
|
347
|
+
segments += 1;
|
|
348
|
+
phases.push(gesturePhase('move', to, startedAtMs));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const end = points.at(-1);
|
|
352
|
+
phases.push(gesturePhase('end', end, startedAtMs));
|
|
353
|
+
const stability = node.settle === false
|
|
354
|
+
? undefined
|
|
355
|
+
: await awaitNativeStability(client, session, selection, action, positiveNumber(node.timeout_ms) ?? 10_000);
|
|
356
|
+
return {
|
|
357
|
+
kind: 'ui-transport-result',
|
|
358
|
+
output: {
|
|
359
|
+
action,
|
|
360
|
+
resolvedStart: start,
|
|
361
|
+
resolvedEnd: end,
|
|
362
|
+
backend: selection.platform === 'ios' ? 'idb-ui' : 'adb-input-swipe',
|
|
363
|
+
segments,
|
|
364
|
+
...stability,
|
|
365
|
+
},
|
|
366
|
+
phases,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async function resolveNativeGesturePoint(client, session, selection, target) {
|
|
370
|
+
if (typeof target !== 'string')
|
|
371
|
+
return roundPoint(target);
|
|
372
|
+
const snapshot = await client.capture.snapshot({
|
|
373
|
+
...selection,
|
|
374
|
+
session,
|
|
375
|
+
interactiveOnly: false,
|
|
376
|
+
forceFull: true,
|
|
377
|
+
});
|
|
378
|
+
const match = snapshot.nodes.find((node) => node.identifier === target);
|
|
379
|
+
if (!match?.rect)
|
|
380
|
+
throw new Error(`Gesture target not found or has no coordinates: ${target}`);
|
|
381
|
+
return roundPoint({
|
|
382
|
+
x: match.rect.x + match.rect.width / 2,
|
|
383
|
+
y: match.rect.y + match.rect.height / 2,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
async function runNativeSwipe(runner, tool, selection, from, to, durationMs) {
|
|
387
|
+
if (selection.platform === 'ios') {
|
|
388
|
+
await runner.execFile(tool, [
|
|
389
|
+
'ui',
|
|
390
|
+
'swipe',
|
|
391
|
+
String(from.x),
|
|
392
|
+
String(from.y),
|
|
393
|
+
String(to.x),
|
|
394
|
+
String(to.y),
|
|
395
|
+
'--duration',
|
|
396
|
+
String(durationMs / 1000),
|
|
397
|
+
'--udid',
|
|
398
|
+
selection.device,
|
|
399
|
+
], { timeoutMs: durationMs + 10_000 });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
await runner.execFile(tool, [
|
|
403
|
+
'-s',
|
|
404
|
+
selection.device,
|
|
405
|
+
'shell',
|
|
406
|
+
'input',
|
|
407
|
+
'swipe',
|
|
408
|
+
String(from.x),
|
|
409
|
+
String(from.y),
|
|
410
|
+
String(to.x),
|
|
411
|
+
String(to.y),
|
|
412
|
+
String(durationMs),
|
|
413
|
+
], { timeoutMs: durationMs + 10_000 });
|
|
414
|
+
}
|
|
415
|
+
async function runNativeLongPress(runner, tool, selection, point, durationMs) {
|
|
416
|
+
if (selection.platform === 'ios') {
|
|
417
|
+
await runner.execFile(tool, [
|
|
418
|
+
'ui',
|
|
419
|
+
'tap',
|
|
420
|
+
String(point.x),
|
|
421
|
+
String(point.y),
|
|
422
|
+
'--duration',
|
|
423
|
+
String(durationMs / 1000),
|
|
424
|
+
'--udid',
|
|
425
|
+
selection.device,
|
|
426
|
+
], { timeoutMs: durationMs + 10_000 });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await runNativeSwipe(runner, tool, selection, point, point, durationMs);
|
|
430
|
+
}
|
|
431
|
+
async function resolveGestureTool(command, configuredPath, runner) {
|
|
432
|
+
const candidate = configuredPath?.trim() || command;
|
|
433
|
+
if (path.isAbsolute(candidate))
|
|
434
|
+
return candidate;
|
|
435
|
+
const result = await runner.execFile('/usr/bin/which', [candidate], { timeoutMs: 5_000 });
|
|
436
|
+
const resolved = result.stdout?.trim();
|
|
437
|
+
if (!resolved || !path.isAbsolute(resolved)) {
|
|
438
|
+
throw new Error(`Native gesture execution requires ${command}. Next: install ${command} and ensure it is on PATH.`);
|
|
439
|
+
}
|
|
440
|
+
return resolved;
|
|
441
|
+
}
|
|
442
|
+
async function resolveIosGestureDevice(device, idbPath, runner) {
|
|
443
|
+
if (isIosUdid(device))
|
|
444
|
+
return device;
|
|
445
|
+
const result = await runner.execFile(idbPath, ['list-targets', '--json'], { timeoutMs: 10_000 });
|
|
446
|
+
const matches = (result.stdout ?? '')
|
|
447
|
+
.split(/\r?\n/u)
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.flatMap((line) => {
|
|
450
|
+
try {
|
|
451
|
+
const target = JSON.parse(line);
|
|
452
|
+
return target.name === device && typeof target.udid === 'string' ? [target] : [];
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// idb may mix diagnostic lines into its newline-delimited JSON output.
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
const match = matches.find(({ state }) => state === 'Booted') ?? matches[0];
|
|
460
|
+
if (!match || typeof match.udid !== 'string') {
|
|
461
|
+
throw new Error(`Native iOS gestures could not resolve simulator ${device}. Next: run idb list-targets and use a listed simulator name or UDID.`);
|
|
462
|
+
}
|
|
463
|
+
return match.udid;
|
|
464
|
+
}
|
|
465
|
+
function agentDeviceSelection(platform, device) {
|
|
466
|
+
if (platform === 'android')
|
|
467
|
+
return { platform, target: 'mobile', serial: device };
|
|
468
|
+
if (isIosUdid(device))
|
|
469
|
+
return { platform, target: 'mobile', udid: device };
|
|
470
|
+
return { platform, target: 'mobile', device };
|
|
471
|
+
}
|
|
472
|
+
function isIosUdid(value) {
|
|
473
|
+
return /^[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}$/iu.test(value);
|
|
474
|
+
}
|
|
475
|
+
function defaultGestureCommandRunner() {
|
|
476
|
+
return {
|
|
477
|
+
async execFile(file, args, options) {
|
|
478
|
+
try {
|
|
479
|
+
return await execFileAsync(file, args, {
|
|
480
|
+
encoding: 'utf8',
|
|
481
|
+
timeout: options?.timeoutMs,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (!(error instanceof Error))
|
|
486
|
+
throw error;
|
|
487
|
+
const output = error;
|
|
488
|
+
const detail = [output.message, output.stderr, output.stdout].filter(Boolean).join('\n');
|
|
489
|
+
throw new Error(`${file} ${args.join(' ')} failed: ${detail}`);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function roundPoint(point) {
|
|
495
|
+
return { x: Math.round(point.x), y: Math.round(point.y) };
|
|
496
|
+
}
|
|
497
|
+
export function assertAgentDeviceNodeVersion(version) {
|
|
498
|
+
const [major = 0, minor = 0] = version.split('.').map(Number);
|
|
499
|
+
if (major > 22 || (major === 22 && minor >= 12))
|
|
500
|
+
return;
|
|
501
|
+
throw new Error(`Native Agent Device recipe actions require Node >=22.12; current runtime is ${version}. Non-native @farmslot/adapter-rn usage supports Node >=20.10.`);
|
|
502
|
+
}
|
|
503
|
+
async function awaitNativeStability(client, session, selection, action, timeoutMs) {
|
|
504
|
+
try {
|
|
505
|
+
const stability = await client.command.wait({
|
|
506
|
+
...selection,
|
|
507
|
+
session,
|
|
508
|
+
stable: true,
|
|
509
|
+
quietMs: 300,
|
|
510
|
+
timeoutMs,
|
|
511
|
+
});
|
|
512
|
+
return { stability };
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
// The action itself already succeeded; unconfirmed settlement surfaces as a
|
|
516
|
+
// visible warning instead of failing the node, matching the CDP transport.
|
|
517
|
+
return {
|
|
518
|
+
settlementWarning: `${action} did not reach a settled native UI state: ${error instanceof Error ? error.message : String(error)}`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function requireSettledInteraction(action, result, settleRequired) {
|
|
523
|
+
const record = result;
|
|
524
|
+
let outcome = result;
|
|
525
|
+
if (settleRequired && record.settle?.settled !== true) {
|
|
526
|
+
// Match the CDP transport contract: a successful action with unconfirmed
|
|
527
|
+
// settlement passes and surfaces a visible settlement warning instead of failing.
|
|
528
|
+
outcome = {
|
|
529
|
+
...outcome,
|
|
530
|
+
settlementWarning: `${action} did not reach a settled native UI state${record.settle?.hint ? `: ${record.settle.hint}` : '.'}`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (record.targetHittable === false && record.evidence?.changedFromBefore === false) {
|
|
534
|
+
outcome = {
|
|
535
|
+
...outcome,
|
|
536
|
+
warning: `${action} settled after resolving a non-hittable target with no accessibility-tree change; verify the passive observation.`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return outcome;
|
|
540
|
+
}
|
|
541
|
+
function sanitizeFillResult(result) {
|
|
542
|
+
const record = result;
|
|
543
|
+
return {
|
|
544
|
+
redacted: true,
|
|
545
|
+
message: 'Filled input',
|
|
546
|
+
targetKind: record.targetKind,
|
|
547
|
+
selector: record.selector,
|
|
548
|
+
targetHittable: record.targetHittable,
|
|
549
|
+
evidence: record.evidence,
|
|
550
|
+
settle: record.settle
|
|
551
|
+
? {
|
|
552
|
+
settled: record.settle.settled,
|
|
553
|
+
waitedMs: record.settle.waitedMs,
|
|
554
|
+
captures: record.settle.captures,
|
|
555
|
+
quietMs: record.settle.quietMs,
|
|
556
|
+
timeoutMs: record.settle.timeoutMs,
|
|
557
|
+
diff: record.settle.diff
|
|
558
|
+
? {
|
|
559
|
+
summary: record.settle.diff.summary,
|
|
560
|
+
truncated: record.settle.diff.truncated,
|
|
561
|
+
}
|
|
562
|
+
: undefined,
|
|
563
|
+
}
|
|
564
|
+
: undefined,
|
|
565
|
+
warning: record.warning,
|
|
566
|
+
settlementWarning: record.settlementWarning,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async function waitForNode(client, session, selection, node) {
|
|
570
|
+
const timeoutMs = positiveNumber(node.timeout_ms) ?? 10_000;
|
|
571
|
+
const expected = node.hidden === true ? 'hidden' : (optionalString(node.expected) ?? 'present');
|
|
572
|
+
const deadline = Date.now() + timeoutMs;
|
|
573
|
+
let nodeCount = 0;
|
|
574
|
+
do {
|
|
575
|
+
const snapshot = await client.capture.snapshot({
|
|
576
|
+
...selection,
|
|
577
|
+
session,
|
|
578
|
+
interactiveOnly: false,
|
|
579
|
+
forceFull: true,
|
|
580
|
+
});
|
|
581
|
+
nodeCount = snapshot.nodes.length;
|
|
582
|
+
const present = snapshotMatches(snapshot.nodes, node);
|
|
583
|
+
const visible = snapshotMatches(snapshot.nodes.filter(isVisibleNode), node);
|
|
584
|
+
const matched = expected === 'absent' || expected === 'not_present'
|
|
585
|
+
? !present
|
|
586
|
+
: expected === 'hidden'
|
|
587
|
+
? !visible
|
|
588
|
+
: expected === 'visible'
|
|
589
|
+
? visible
|
|
590
|
+
: present;
|
|
591
|
+
if (matched) {
|
|
592
|
+
return { matched: true, expected };
|
|
593
|
+
}
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
595
|
+
} while (Date.now() < deadline);
|
|
596
|
+
throw new Error(`ui.wait_for timed out after ${timeoutMs}ms (${nodeCount} nodes observed).`);
|
|
597
|
+
}
|
|
598
|
+
async function captureScreenshot(client, session, node, context) {
|
|
599
|
+
const relativePath = optionalString(node.path) ?? `screenshots/${context.nodeId}.png`;
|
|
600
|
+
const normalizedPath = relativePath.split(path.sep).join('/');
|
|
601
|
+
const outputPath = context.resolveArtifactPath(normalizedPath);
|
|
602
|
+
const result = await client.capture.screenshot({ session, path: outputPath, stabilize: true });
|
|
603
|
+
const artifact = {
|
|
604
|
+
path: normalizedPath,
|
|
605
|
+
type: 'screenshot',
|
|
606
|
+
nodeId: context.nodeId,
|
|
607
|
+
mimeType: 'image/png',
|
|
608
|
+
label: optionalString(node.label) ?? 'UI screenshot',
|
|
609
|
+
};
|
|
610
|
+
context.registerArtifact(artifact);
|
|
611
|
+
return {
|
|
612
|
+
kind: 'ui-transport-result',
|
|
613
|
+
output: { captured: true, path: normalizedPath, width: result.width, height: result.height },
|
|
614
|
+
control: { artifacts: [artifact] },
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
async function captureSurface(client, session, selection, node, context) {
|
|
618
|
+
const relativePath = optionalString(node.path) ?? `screenshots/${context.nodeId}.png`;
|
|
619
|
+
const normalizedPath = relativePath.split(path.sep).join('/');
|
|
620
|
+
const outputPath = context.resolveArtifactPath(normalizedPath);
|
|
621
|
+
const surfaceTestId = optionalString(node.surface_test_id ?? node.surfaceTestId);
|
|
622
|
+
const untilTestId = optionalString(node.until_test_id ?? node.untilTestId);
|
|
623
|
+
const maxScrolls = boundedPositiveInteger(node.max_scrolls ?? node.maxScrolls, 12, 60);
|
|
624
|
+
const restorePosition = node.restore_position !== false && node.restorePosition !== false;
|
|
625
|
+
const temporaryDir = context.resolveArtifactPath(`.capture-surface/${context.nodeId.replaceAll(/[^a-zA-Z0-9._-]/gu, '-')}`);
|
|
626
|
+
const pages = [];
|
|
627
|
+
let reachedEnd = false;
|
|
628
|
+
let surfaceRect;
|
|
629
|
+
let surfaceResolved = false;
|
|
630
|
+
let failure;
|
|
631
|
+
let captureResult;
|
|
632
|
+
let captureArtifact;
|
|
633
|
+
await mkdir(temporaryDir, { recursive: true });
|
|
634
|
+
try {
|
|
635
|
+
surfaceRect = await resolveSurfaceRect(client, session, selection, surfaceTestId);
|
|
636
|
+
surfaceResolved = true;
|
|
637
|
+
await resetSurfaceToTop(client, session, selection, surfaceRect, maxScrolls);
|
|
638
|
+
const firstPath = path.join(temporaryDir, 'page-00.png');
|
|
639
|
+
const firstScreenshot = await client.capture.screenshot({
|
|
640
|
+
session,
|
|
641
|
+
path: firstPath,
|
|
642
|
+
stabilize: true,
|
|
643
|
+
});
|
|
644
|
+
const firstImage = PNG.sync.read(await readFile(firstPath));
|
|
645
|
+
pages.push({
|
|
646
|
+
path: firstPath,
|
|
647
|
+
surface: physicalRectForSurface(surfaceRect, firstScreenshot, firstImage, selection.platform),
|
|
648
|
+
});
|
|
649
|
+
reachedEnd = await surfaceEndIsVisible(client, session, selection, surfaceTestId, untilTestId);
|
|
650
|
+
let captureAttempt = 0;
|
|
651
|
+
let movedScrolls = 0;
|
|
652
|
+
let consecutiveDroppedMoves = 0;
|
|
653
|
+
while (movedScrolls < maxScrolls && !reachedEnd) {
|
|
654
|
+
captureAttempt += 1;
|
|
655
|
+
const requestedPoints = await moveSurface(client, session, selection, surfaceRect, 'toward-bottom');
|
|
656
|
+
const pagePath = path.join(temporaryDir, `page-${String(captureAttempt).padStart(2, '0')}.png`);
|
|
657
|
+
const screenshot = await client.capture.screenshot({
|
|
658
|
+
session,
|
|
659
|
+
path: pagePath,
|
|
660
|
+
stabilize: true,
|
|
661
|
+
});
|
|
662
|
+
const previous = PNG.sync.read(await readFile(pages.at(-1).path));
|
|
663
|
+
const current = PNG.sync.read(await readFile(pagePath));
|
|
664
|
+
const currentSurface = physicalRectForSurface(surfaceRect, screenshot, current, selection.platform);
|
|
665
|
+
const requestedRows = Math.round(requestedPoints * (currentSurface.height / surfaceRect.height));
|
|
666
|
+
const revealedRows = measureRevealedRows(previous, current, pages.at(-1).surface, currentSurface, requestedRows);
|
|
667
|
+
if (revealedRows === 0) {
|
|
668
|
+
reachedEnd =
|
|
669
|
+
untilTestId === undefined ||
|
|
670
|
+
(await surfaceEndIsVisible(client, session, selection, surfaceTestId, untilTestId, true));
|
|
671
|
+
if (!reachedEnd) {
|
|
672
|
+
consecutiveDroppedMoves += 1;
|
|
673
|
+
if (consecutiveDroppedMoves <= 2)
|
|
674
|
+
continue;
|
|
675
|
+
throw new Error(`ui.capture_surface stopped moving before reaching until_test_id=${untilTestId}.`);
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
movedScrolls += 1;
|
|
680
|
+
consecutiveDroppedMoves = 0;
|
|
681
|
+
pages.push({
|
|
682
|
+
path: pagePath,
|
|
683
|
+
surface: currentSurface,
|
|
684
|
+
revealedRows,
|
|
685
|
+
});
|
|
686
|
+
reachedEnd = await surfaceEndIsVisible(client, session, selection, surfaceTestId, untilTestId);
|
|
687
|
+
}
|
|
688
|
+
if (!reachedEnd) {
|
|
689
|
+
throw new Error(`ui.capture_surface did not reach the end within ${maxScrolls} scrolls. Increase max_scrolls or provide an earlier until_test_id.`);
|
|
690
|
+
}
|
|
691
|
+
const stitched = await stitchSurfacePages(pages);
|
|
692
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
693
|
+
await writeFile(outputPath, PNG.sync.write(stitched));
|
|
694
|
+
const artifact = {
|
|
695
|
+
path: normalizedPath,
|
|
696
|
+
type: 'screenshot',
|
|
697
|
+
nodeId: context.nodeId,
|
|
698
|
+
mimeType: 'image/png',
|
|
699
|
+
label: optionalString(node.label) ?? 'Full UI surface',
|
|
700
|
+
};
|
|
701
|
+
captureArtifact = artifact;
|
|
702
|
+
captureResult = {
|
|
703
|
+
kind: 'ui-transport-result',
|
|
704
|
+
output: {
|
|
705
|
+
captured: true,
|
|
706
|
+
extent: 'full',
|
|
707
|
+
path: normalizedPath,
|
|
708
|
+
width: stitched.width,
|
|
709
|
+
height: stitched.height,
|
|
710
|
+
viewports: pages.length,
|
|
711
|
+
},
|
|
712
|
+
control: { artifacts: [artifact] },
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
failure = { error };
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
try {
|
|
720
|
+
if (restorePosition && surfaceResolved) {
|
|
721
|
+
await resetSurfaceToTop(client, session, selection, surfaceRect, maxScrolls);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch (restoreError) {
|
|
725
|
+
failure ??= { error: restoreError };
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
await rm(temporaryDir, { recursive: true, force: true });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (failure)
|
|
732
|
+
throw failure.error;
|
|
733
|
+
if (!captureResult || !captureArtifact) {
|
|
734
|
+
throw new Error('ui.capture_surface produced no result.');
|
|
735
|
+
}
|
|
736
|
+
context.registerArtifact(captureArtifact);
|
|
737
|
+
return captureResult;
|
|
738
|
+
}
|
|
739
|
+
async function resolveSurfaceRect(client, session, selection, surfaceTestId) {
|
|
740
|
+
const snapshot = await client.capture.snapshot({
|
|
741
|
+
...selection,
|
|
742
|
+
session,
|
|
743
|
+
interactiveOnly: false,
|
|
744
|
+
forceFull: true,
|
|
745
|
+
});
|
|
746
|
+
const requestedSurface = surfaceTestId
|
|
747
|
+
? snapshot.nodes.find((candidate) => candidate.identifier === surfaceTestId)
|
|
748
|
+
: undefined;
|
|
749
|
+
if (surfaceTestId && !requestedSurface?.rect) {
|
|
750
|
+
throw new Error(`ui.capture_surface could not resolve surface_test_id=${surfaceTestId}.`);
|
|
751
|
+
}
|
|
752
|
+
const scrollSurface = requestedSurface?.type === 'ScrollView'
|
|
753
|
+
? requestedSurface
|
|
754
|
+
: snapshot.nodes
|
|
755
|
+
.filter((candidate) => candidate.type === 'ScrollView' &&
|
|
756
|
+
candidate.rect !== undefined &&
|
|
757
|
+
(!requestedSurface?.rect || rectsIntersect(candidate.rect, requestedSurface.rect)))
|
|
758
|
+
.sort((left, right) => right.rect.width * right.rect.height - left.rect.width * left.rect.height)[0];
|
|
759
|
+
const surface = scrollSurface ??
|
|
760
|
+
requestedSurface ??
|
|
761
|
+
snapshot.nodes
|
|
762
|
+
.filter((candidate) => /Application|Window/u.test(candidate.type ?? '') && candidate.rect)
|
|
763
|
+
.sort((left, right) => right.rect.width * right.rect.height - left.rect.width * left.rect.height)[0];
|
|
764
|
+
if (!surface?.rect || surface.rect.width <= 0 || surface.rect.height <= 0) {
|
|
765
|
+
throw new Error(surfaceTestId
|
|
766
|
+
? `ui.capture_surface could not resolve surface_test_id=${surfaceTestId}.`
|
|
767
|
+
: 'ui.capture_surface could not resolve the native viewport.');
|
|
768
|
+
}
|
|
769
|
+
return surface.rect;
|
|
770
|
+
}
|
|
771
|
+
async function resetSurfaceToTop(client, session, selection, surfaceRect, maxScrolls) {
|
|
772
|
+
for (let index = 0; index <= maxScrolls; index += 1) {
|
|
773
|
+
const snapshot = await client.capture.snapshot({
|
|
774
|
+
...selection,
|
|
775
|
+
session,
|
|
776
|
+
interactiveOnly: false,
|
|
777
|
+
forceFull: true,
|
|
778
|
+
});
|
|
779
|
+
const hasHiddenContentAbove = snapshot.nodes.some((candidate) => candidate.hiddenContentAbove === true &&
|
|
780
|
+
candidate.rect !== undefined &&
|
|
781
|
+
rectsIntersect(candidate.rect, surfaceRect));
|
|
782
|
+
if (!hasHiddenContentAbove)
|
|
783
|
+
return;
|
|
784
|
+
if (index === maxScrolls) {
|
|
785
|
+
throw new Error(`ui.capture_surface could not reset to the top within ${maxScrolls} scrolls.`);
|
|
786
|
+
}
|
|
787
|
+
await moveSurface(client, session, selection, surfaceRect, 'toward-top');
|
|
788
|
+
await awaitNativeStability(client, session, selection, 'ui.capture_surface', 10_000);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async function moveSurface(client, session, selection, surfaceRect, direction) {
|
|
792
|
+
const upperY = surfaceRect.y + surfaceRect.height * 0.2;
|
|
793
|
+
const lowerY = surfaceRect.y + surfaceRect.height * 0.8;
|
|
794
|
+
const pixels = Math.max(1, Math.round(lowerY - upperY));
|
|
795
|
+
// Stay near the edge to avoid nested scroll views, but outside iOS's
|
|
796
|
+
// system back-gesture zone where a vertical swipe can be dropped.
|
|
797
|
+
const x = surfaceRect.x + Math.min(32, surfaceRect.width * 0.1);
|
|
798
|
+
if (direction === 'toward-bottom') {
|
|
799
|
+
await client.interactions.swipe({
|
|
800
|
+
...selection,
|
|
801
|
+
session,
|
|
802
|
+
from: { x, y: lowerY },
|
|
803
|
+
to: { x, y: upperY },
|
|
804
|
+
durationMs: 250,
|
|
805
|
+
responseLevel: 'digest',
|
|
806
|
+
});
|
|
807
|
+
return pixels;
|
|
808
|
+
}
|
|
809
|
+
await client.interactions.swipe({
|
|
810
|
+
...selection,
|
|
811
|
+
session,
|
|
812
|
+
from: { x, y: upperY },
|
|
813
|
+
to: { x, y: lowerY },
|
|
814
|
+
durationMs: 250,
|
|
815
|
+
responseLevel: 'digest',
|
|
816
|
+
});
|
|
817
|
+
return pixels;
|
|
818
|
+
}
|
|
819
|
+
async function surfaceEndIsVisible(client, session, selection, surfaceTestId, untilTestId, stoppedMoving = false) {
|
|
820
|
+
if (!untilTestId)
|
|
821
|
+
return false;
|
|
822
|
+
const snapshot = await client.capture.snapshot({
|
|
823
|
+
...selection,
|
|
824
|
+
session,
|
|
825
|
+
interactiveOnly: false,
|
|
826
|
+
forceFull: true,
|
|
827
|
+
});
|
|
828
|
+
const viewport = snapshot.nodes
|
|
829
|
+
.filter((candidate) => /Application|Window/u.test(candidate.type ?? '') && candidate.rect)
|
|
830
|
+
.map((candidate) => candidate.rect)
|
|
831
|
+
.sort((left, right) => right.width * right.height - left.width * left.height)[0];
|
|
832
|
+
const surface = surfaceTestId
|
|
833
|
+
? snapshot.nodes.find((candidate) => candidate.identifier === surfaceTestId)
|
|
834
|
+
: undefined;
|
|
835
|
+
const hasHiddenContentBelow = surface
|
|
836
|
+
? surface.hiddenContentBelow === true
|
|
837
|
+
: snapshot.nodes.some((candidate) => candidate.hiddenContentBelow === true &&
|
|
838
|
+
(!viewport || !candidate.rect || rectsIntersect(candidate.rect, viewport)));
|
|
839
|
+
if (hasHiddenContentBelow && !stoppedMoving)
|
|
840
|
+
return false;
|
|
841
|
+
return snapshot.nodes.some((candidate) => {
|
|
842
|
+
if (candidate.identifier !== untilTestId || !candidate.rect || !isVisibleNode(candidate)) {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
return viewport ? rectsIntersect(candidate.rect, viewport) : true;
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
function physicalRectForSurface(surface, screenshot, image, platform) {
|
|
849
|
+
const logicalWidth = positiveNumber(screenshot.logicalWidth);
|
|
850
|
+
const logicalHeight = positiveNumber(screenshot.logicalHeight);
|
|
851
|
+
const density = positiveNumber(screenshot.pixelDensity);
|
|
852
|
+
const scaleX = logicalWidth
|
|
853
|
+
? image.width / logicalWidth
|
|
854
|
+
: (density ??
|
|
855
|
+
(logicalHeight ? image.height / logicalHeight : platform === 'android' ? 1 : undefined));
|
|
856
|
+
const scaleY = logicalHeight
|
|
857
|
+
? image.height / logicalHeight
|
|
858
|
+
: (density ??
|
|
859
|
+
(logicalWidth ? image.width / logicalWidth : platform === 'android' ? 1 : undefined));
|
|
860
|
+
if (!scaleX || !scaleY) {
|
|
861
|
+
throw new Error('ui.capture_surface requires screenshot logical dimensions or pixel density to map the native surface.');
|
|
862
|
+
}
|
|
863
|
+
const x = Math.max(0, Math.round(surface.x * scaleX));
|
|
864
|
+
const y = Math.max(0, Math.round(surface.y * scaleY));
|
|
865
|
+
const right = Math.min(image.width, Math.round((surface.x + surface.width) * scaleX));
|
|
866
|
+
const bottom = Math.min(image.height, Math.round((surface.y + surface.height) * scaleY));
|
|
867
|
+
if (right <= x || bottom <= y) {
|
|
868
|
+
throw new Error('ui.capture_surface resolved outside the captured screenshot.');
|
|
869
|
+
}
|
|
870
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
871
|
+
}
|
|
872
|
+
function rectsIntersect(left, right) {
|
|
873
|
+
return (left.x < right.x + right.width &&
|
|
874
|
+
left.x + left.width > right.x &&
|
|
875
|
+
left.y < right.y + right.height &&
|
|
876
|
+
left.y + left.height > right.y);
|
|
877
|
+
}
|
|
878
|
+
async function stitchSurfacePages(pages) {
|
|
879
|
+
const firstPage = pages[0];
|
|
880
|
+
if (!firstPage)
|
|
881
|
+
throw new Error('ui.capture_surface did not capture a viewport.');
|
|
882
|
+
const first = PNG.sync.read(await readFile(firstPage.path));
|
|
883
|
+
if (pages.some((page) => page.surface.width !== firstPage.surface.width ||
|
|
884
|
+
page.surface.height !== firstPage.surface.height)) {
|
|
885
|
+
throw new Error('ui.capture_surface viewport or surface dimensions changed during capture.');
|
|
886
|
+
}
|
|
887
|
+
const revealed = pages.slice(1).map((page) => page.revealedRows);
|
|
888
|
+
const firstSurface = firstPage.surface;
|
|
889
|
+
const lastSurface = pages.at(-1).surface;
|
|
890
|
+
const isFullWidth = pages.every((page) => page.surface.x === 0 && page.surface.width === first.width);
|
|
891
|
+
const leadingRows = isFullWidth ? firstSurface.y : 0;
|
|
892
|
+
const trailingRows = isFullWidth ? first.height - (lastSurface.y + lastSurface.height) : 0;
|
|
893
|
+
const output = new PNG({
|
|
894
|
+
width: isFullWidth ? first.width : firstSurface.width,
|
|
895
|
+
height: leadingRows +
|
|
896
|
+
revealed.reduce((sum, pixels) => sum + pixels, 0) +
|
|
897
|
+
lastSurface.height +
|
|
898
|
+
trailingRows,
|
|
899
|
+
});
|
|
900
|
+
if (leadingRows > 0)
|
|
901
|
+
copyImageRows(first, output, 0, 0, leadingRows, 0);
|
|
902
|
+
let outputY = leadingRows;
|
|
903
|
+
let sourceStart = 0;
|
|
904
|
+
let image = first;
|
|
905
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
906
|
+
const surface = pages[index].surface;
|
|
907
|
+
const nextPage = pages[index + 1];
|
|
908
|
+
const nextImage = nextPage ? PNG.sync.read(await readFile(nextPage.path)) : undefined;
|
|
909
|
+
if (nextImage && (nextImage.width !== first.width || nextImage.height !== first.height)) {
|
|
910
|
+
throw new Error('ui.capture_surface viewport or surface dimensions changed during capture.');
|
|
911
|
+
}
|
|
912
|
+
const sourceEnd = nextImage && nextPage
|
|
913
|
+
? chooseSurfaceSeam(image, nextImage, surface, nextPage.surface, revealed[index])
|
|
914
|
+
: surface.height;
|
|
915
|
+
const rows = sourceEnd - sourceStart;
|
|
916
|
+
copyImageRows(image, output, isFullWidth ? 0 : surface.x, surface.y + sourceStart, rows, outputY);
|
|
917
|
+
outputY += rows;
|
|
918
|
+
if (nextImage) {
|
|
919
|
+
sourceStart = sourceEnd - revealed[index];
|
|
920
|
+
image = nextImage;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (trailingRows > 0) {
|
|
924
|
+
copyImageRows(image, output, 0, lastSurface.y + lastSurface.height, trailingRows, outputY);
|
|
925
|
+
}
|
|
926
|
+
return output;
|
|
927
|
+
}
|
|
928
|
+
function chooseSurfaceSeam(current, next, currentSurface, nextSurface, revealedRows) {
|
|
929
|
+
const minimum = Math.min(currentSurface.height - 2, revealedRows + 2);
|
|
930
|
+
const maximum = currentSurface.height - 2;
|
|
931
|
+
let bestRow = revealedRows;
|
|
932
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
933
|
+
for (let currentRow = minimum; currentRow <= maximum; currentRow += 1) {
|
|
934
|
+
const nextRow = currentRow - revealedRows;
|
|
935
|
+
const score = sampledSeamDifference(current, next, currentSurface, nextSurface, currentRow, nextRow);
|
|
936
|
+
if (score < bestScore) {
|
|
937
|
+
bestScore = score;
|
|
938
|
+
bestRow = currentRow;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return bestRow;
|
|
942
|
+
}
|
|
943
|
+
function sampledSeamDifference(current, next, currentSurface, nextSurface, currentRow, nextRow) {
|
|
944
|
+
const sampleLeft = Math.max(1, Math.floor(currentSurface.width * 0.05));
|
|
945
|
+
const sampleRight = Math.max(sampleLeft + 1, Math.floor(currentSurface.width * 0.8));
|
|
946
|
+
let difference = 0;
|
|
947
|
+
let samples = 0;
|
|
948
|
+
for (let x = sampleLeft; x < sampleRight; x += 3) {
|
|
949
|
+
for (const channel of [0, 1, 2]) {
|
|
950
|
+
const currentOffset = ((currentSurface.y + currentRow) * current.width + currentSurface.x + x) * 4 + channel;
|
|
951
|
+
const nextOffset = ((nextSurface.y + nextRow) * next.width + nextSurface.x + x) * 4 + channel;
|
|
952
|
+
const currentAbove = currentOffset - current.width * 4;
|
|
953
|
+
const currentBelow = currentOffset + current.width * 4;
|
|
954
|
+
const nextAbove = nextOffset - next.width * 4;
|
|
955
|
+
const nextBelow = nextOffset + next.width * 4;
|
|
956
|
+
difference += Math.abs(current.data[currentOffset] - next.data[nextOffset]) * 2;
|
|
957
|
+
difference += Math.abs(current.data[currentAbove] - current.data[currentBelow]);
|
|
958
|
+
difference += Math.abs(next.data[nextAbove] - next.data[nextBelow]);
|
|
959
|
+
samples += 4;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return samples > 0 ? difference / samples : Number.POSITIVE_INFINITY;
|
|
963
|
+
}
|
|
964
|
+
function copyImageRows(source, destination, sourceX, sourceY, rows, destinationY) {
|
|
965
|
+
for (let row = 0; row < rows; row += 1) {
|
|
966
|
+
const sourceStart = ((sourceY + row) * source.width + sourceX) * 4;
|
|
967
|
+
const sourceEnd = sourceStart + destination.width * 4;
|
|
968
|
+
source.data.copy(destination.data, (destinationY + row) * destination.width * 4, sourceStart, sourceEnd);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function measureRevealedRows(previous, current, previousSurface, currentSurface, requestedRows) {
|
|
972
|
+
const height = previousSurface.height;
|
|
973
|
+
const minimumOverlap = Math.max(4, Math.floor(height * 0.12));
|
|
974
|
+
const stationaryDifference = sampledSurfaceDifference(previous, current, previousSurface, currentSurface, 0, false);
|
|
975
|
+
if (stationaryDifference < 10)
|
|
976
|
+
return 0;
|
|
977
|
+
let bestDelta = 0;
|
|
978
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
979
|
+
let bestDifference = Number.POSITIVE_INFINITY;
|
|
980
|
+
for (let delta = 1; delta <= height - minimumOverlap; delta += 1) {
|
|
981
|
+
const meanDifference = sampledSurfaceDifference(previous, current, previousSurface, currentSurface, delta);
|
|
982
|
+
const distancePenalty = requestedRows > 0 ? (Math.abs(delta - requestedRows) / requestedRows) * 8 : 0;
|
|
983
|
+
const score = meanDifference + distancePenalty;
|
|
984
|
+
if (score < bestScore) {
|
|
985
|
+
bestScore = score;
|
|
986
|
+
bestDifference = meanDifference;
|
|
987
|
+
bestDelta = delta;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (bestDelta === 0 || bestDifference > 24) {
|
|
991
|
+
throw new Error(`ui.capture_surface could not prove screenshot overlap (best mean difference ${bestDifference.toFixed(1)}).`);
|
|
992
|
+
}
|
|
993
|
+
return bestDelta;
|
|
994
|
+
}
|
|
995
|
+
function sampledSurfaceDifference(previous, current, previousSurface, currentSurface, delta, trimOutliers = true) {
|
|
996
|
+
const overlap = previousSurface.height - delta;
|
|
997
|
+
const differences = [];
|
|
998
|
+
const sampleLeft = Math.max(1, Math.floor(previousSurface.width * 0.05));
|
|
999
|
+
const sampleRight = Math.max(sampleLeft + 1, Math.floor(previousSurface.width * 0.8));
|
|
1000
|
+
for (let y = Math.floor(overlap * 0.08); y < overlap; y += 4) {
|
|
1001
|
+
for (let x = sampleLeft; x < sampleRight; x += 4) {
|
|
1002
|
+
const previousOffset = ((previousSurface.y + y + delta) * previous.width + previousSurface.x + x) * 4;
|
|
1003
|
+
const currentOffset = ((currentSurface.y + y) * current.width + currentSurface.x + x) * 4;
|
|
1004
|
+
differences.push((Math.abs(previous.data[previousOffset] - current.data[currentOffset]) +
|
|
1005
|
+
Math.abs(previous.data[previousOffset + 1] - current.data[currentOffset + 1]) +
|
|
1006
|
+
Math.abs(previous.data[previousOffset + 2] - current.data[currentOffset + 2])) /
|
|
1007
|
+
3);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (differences.length === 0)
|
|
1011
|
+
return Number.POSITIVE_INFINITY;
|
|
1012
|
+
differences.sort((left, right) => left - right);
|
|
1013
|
+
const retained = trimOutliers
|
|
1014
|
+
? differences.slice(0, Math.max(1, Math.ceil(differences.length * 0.8)))
|
|
1015
|
+
: differences;
|
|
1016
|
+
return retained.reduce((sum, value) => sum + value, 0) / retained.length;
|
|
1017
|
+
}
|
|
1018
|
+
function boundedPositiveInteger(value, fallback, maximum) {
|
|
1019
|
+
const parsed = value == null ? fallback : Number(value);
|
|
1020
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
1021
|
+
throw new Error(`Expected a positive integer no greater than ${maximum}; got ${String(value)}.`);
|
|
1022
|
+
}
|
|
1023
|
+
return parsed;
|
|
1024
|
+
}
|
|
1025
|
+
function observationsFromSnapshot(refs, snapshot) {
|
|
1026
|
+
const observations = {};
|
|
1027
|
+
const warnings = [];
|
|
1028
|
+
for (const ref of refs) {
|
|
1029
|
+
if (ref === 'ui.screen') {
|
|
1030
|
+
observations[ref] = {
|
|
1031
|
+
provider: 'agent-device',
|
|
1032
|
+
name: snapshot.appName,
|
|
1033
|
+
app_id: snapshot.appBundleId,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
else if (ref === 'ui.visible') {
|
|
1037
|
+
const actionable = snapshot.nodes.filter(isActionableNode);
|
|
1038
|
+
const visible = actionable.filter(isVisibleNode);
|
|
1039
|
+
const hidden = actionable.filter((node) => !isVisibleNode(node));
|
|
1040
|
+
observations[ref] = {
|
|
1041
|
+
provider: 'agent-device',
|
|
1042
|
+
items: visible.slice(0, 20).map((node) => ({
|
|
1043
|
+
test_id: node.identifier,
|
|
1044
|
+
label: node.label,
|
|
1045
|
+
role: node.type,
|
|
1046
|
+
})),
|
|
1047
|
+
hidden_or_offscreen: hidden.slice(0, 10).map((node) => ({
|
|
1048
|
+
test_id: node.identifier,
|
|
1049
|
+
role: node.type,
|
|
1050
|
+
reason: node.interactionBlocked === 'covered' ? 'covered' : 'hidden_or_offscreen',
|
|
1051
|
+
})),
|
|
1052
|
+
truncated: snapshot.truncated || visible.length > 20 || hidden.length > 10,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
warnings.push({ ref, message: `Agent Device does not support UI observer ${ref}.` });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
...(Object.keys(observations).length ? { observations } : {}),
|
|
1061
|
+
...(warnings.length ? { warnings } : {}),
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function isActionableNode(node) {
|
|
1065
|
+
if (node.hittable)
|
|
1066
|
+
return true;
|
|
1067
|
+
const type = node.type?.toLowerCase() ?? '';
|
|
1068
|
+
return ['button', 'cell', 'link', 'switch', 'textfield', 'text-field'].some((role) => type.includes(role));
|
|
1069
|
+
}
|
|
1070
|
+
function isVisibleNode(node) {
|
|
1071
|
+
return node.visibleToUser !== false && node.interactionBlocked !== 'covered';
|
|
1072
|
+
}
|
|
1073
|
+
function snapshotMatches(nodes, node) {
|
|
1074
|
+
const testId = optionalString(node.test_id ?? node.testID);
|
|
1075
|
+
const exactText = optionalString(node.text ?? node.label);
|
|
1076
|
+
const contains = Array.isArray(node.text_contains)
|
|
1077
|
+
? node.text_contains.filter((value) => typeof value === 'string')
|
|
1078
|
+
: [];
|
|
1079
|
+
if (!testId && !exactText && contains.length === 0) {
|
|
1080
|
+
throw new Error('ui.wait_for requires test_id, text, label, or text_contains.');
|
|
1081
|
+
}
|
|
1082
|
+
const matchesIdentity = nodes.some((candidate) => {
|
|
1083
|
+
if (testId && candidate.identifier !== testId)
|
|
1084
|
+
return false;
|
|
1085
|
+
if (exactText &&
|
|
1086
|
+
![candidate.label, candidate.value, candidate.identifier].some((value) => typeof value === 'string' && value.includes(exactText))) {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
return true;
|
|
1090
|
+
});
|
|
1091
|
+
if ((testId || exactText) && !matchesIdentity)
|
|
1092
|
+
return false;
|
|
1093
|
+
return contains.every((text) => nodes.some((candidate) => [candidate.label, candidate.value, candidate.identifier]
|
|
1094
|
+
.filter((value) => typeof value === 'string')
|
|
1095
|
+
.some((value) => value.includes(text))));
|
|
1096
|
+
}
|
|
1097
|
+
function selectorFromNode(node, action) {
|
|
1098
|
+
const selector = optionalString(node.selector);
|
|
1099
|
+
if (selector)
|
|
1100
|
+
return selector;
|
|
1101
|
+
const testId = optionalString(node.test_id ?? node.testID);
|
|
1102
|
+
if (testId)
|
|
1103
|
+
return `id=${JSON.stringify(testId)}`;
|
|
1104
|
+
const label = optionalString(node.text ?? node.label);
|
|
1105
|
+
if (label)
|
|
1106
|
+
return `label=${JSON.stringify(label)}`;
|
|
1107
|
+
throw new Error(`${action} requires selector, test_id, text, or label.`);
|
|
1108
|
+
}
|
|
1109
|
+
/** Map one-axis delta_x/delta_y onto Agent Device's direction + pixels relative scroll. */
|
|
1110
|
+
function relativeScrollDelta(node) {
|
|
1111
|
+
const dx = node.delta_x;
|
|
1112
|
+
const dy = node.delta_y;
|
|
1113
|
+
if (dx === undefined && dy === undefined)
|
|
1114
|
+
return undefined;
|
|
1115
|
+
if (node.direction !== undefined || node.pixels !== undefined || node.amount !== undefined) {
|
|
1116
|
+
throw new Error('ui.scroll delta_x/delta_y cannot be combined with direction, pixels, or amount.');
|
|
1117
|
+
}
|
|
1118
|
+
if (dx !== undefined && dy !== undefined) {
|
|
1119
|
+
throw new Error('ui.scroll on Agent Device moves one axis at a time; pass delta_x or delta_y.');
|
|
1120
|
+
}
|
|
1121
|
+
const value = dx ?? dy;
|
|
1122
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value === 0) {
|
|
1123
|
+
throw new Error('ui.scroll delta_x/delta_y must be a non-zero number.');
|
|
1124
|
+
}
|
|
1125
|
+
const direction = dy !== undefined ? (value > 0 ? 'down' : 'up') : value > 0 ? 'right' : 'left';
|
|
1126
|
+
return { direction, pixels: Math.abs(value) };
|
|
1127
|
+
}
|
|
1128
|
+
function scrollDirection(node) {
|
|
1129
|
+
const direction = optionalString(node.direction) ?? 'down';
|
|
1130
|
+
if (direction === 'up' || direction === 'down' || direction === 'left' || direction === 'right') {
|
|
1131
|
+
return direction;
|
|
1132
|
+
}
|
|
1133
|
+
throw new Error(`ui.scroll.direction must be up, down, left, or right; got ${direction}.`);
|
|
1134
|
+
}
|
|
1135
|
+
function requiredString(value, field) {
|
|
1136
|
+
const parsed = optionalString(value);
|
|
1137
|
+
if (!parsed)
|
|
1138
|
+
throw new Error(`${field} requires a non-empty string.`);
|
|
1139
|
+
return parsed;
|
|
1140
|
+
}
|
|
1141
|
+
function optionalString(value) {
|
|
1142
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
1143
|
+
}
|
|
1144
|
+
function positiveNumber(value) {
|
|
1145
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
1146
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
1147
|
+
}
|
|
1148
|
+
//# sourceMappingURL=agent-device-ui-transport.js.map
|