@e2e-dev/mobile 0.12.1-nightly-20261013094930 → 0.12.1
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/dist/.build.json +1 -1
- package/dist/engine.js +8 -6
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +4 -2
- package/dist/support.d.ts +28 -0
- package/dist/support.js +53 -1
- package/dist/surface.d.ts +9 -6
- package/dist/surface.js +45 -36
- package/package.json +3 -3
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.12.1
|
|
1
|
+
{"version":"0.12.1"}
|
package/dist/engine.js
CHANGED
|
@@ -29,21 +29,23 @@ export function buildEngine(surface) {
|
|
|
29
29
|
dispose: (context) => surface.dispose(context),
|
|
30
30
|
observe: (operation, options) => surface.observe(operation, options),
|
|
31
31
|
locate: (expression, operation) => surface.locate(expression, operation),
|
|
32
|
-
|
|
32
|
+
// The harness signal does not abort at an operation's budget, so each
|
|
33
|
+
// input enforces its own; see InputBudget in support.ts.
|
|
34
|
+
perform: (ref, action, operation) => surface.perform(ref, action, operation, surface.inputBudget(action.kind, operation.timeoutMs)),
|
|
33
35
|
actions: DEVICE_ACTIONS,
|
|
34
|
-
performAt: (point, action, operation) => surface.performAt(point, action, operation),
|
|
36
|
+
performAt: (point, action, operation) => surface.performAt(point, action, operation, surface.inputBudget(action.kind, operation.timeoutMs)),
|
|
35
37
|
pointerActions: DEVICE_POINTER_ACTIONS,
|
|
36
38
|
keyboard: {
|
|
37
|
-
type: (text, keyboardOptions, operation) => surface.typeText(text, keyboardOptions, operation),
|
|
38
|
-
press: (key, operation) => surface.pressFocusedKey(key, operation),
|
|
39
|
-
dismiss: (operation) => surface.dismissKeyboard(operation.signal),
|
|
39
|
+
type: (text, keyboardOptions, operation) => surface.typeText(text, keyboardOptions, operation, surface.inputBudget('type', operation.timeoutMs)),
|
|
40
|
+
press: (key, operation) => surface.pressFocusedKey(key, operation, surface.inputBudget('press', operation.timeoutMs)),
|
|
41
|
+
dismiss: (operation) => surface.dismissKeyboard(operation.signal, surface.inputBudget('keyboard dismiss', operation.timeoutMs)),
|
|
40
42
|
},
|
|
41
43
|
validateApp: (app, info) => validateApp(app, info, surface.options.platform),
|
|
42
44
|
// No `open`: a device app has no URL to open, so the runner serves
|
|
43
45
|
// `app.open()` with `restart`, the fresh launch of the pinned app, which
|
|
44
46
|
// is also the device's "recreate the context".
|
|
45
47
|
session: {
|
|
46
|
-
back: (operation) => surface.back(operation),
|
|
48
|
+
back: (operation) => surface.back(operation, surface.inputBudget('back', operation.timeoutMs)),
|
|
47
49
|
restart: (operation) => surface.restart(operation),
|
|
48
50
|
reset: (operation) => surface.reset(operation),
|
|
49
51
|
},
|
package/dist/errors.d.ts
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* | anything else, agent-device's own timeouts included | ENGINE_FAILURE with the text |
|
|
15
15
|
*
|
|
16
16
|
* `OPERATION_TIMEOUT` is never produced here. In the contract it means the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* operation budget expired: `InputBudget` (support.ts) reports it for an input
|
|
18
|
+
* the budget ran out before, and `ACTION_MAY_HAVE_COMMITTED` for one sent that
|
|
19
|
+
* did not answer in time. Core replaces the timeout's message with a bare
|
|
20
|
+
* `operation timed out`. A timeout
|
|
19
21
|
* agent-device itself reports (a simulator boot, the runner connecting, an
|
|
20
22
|
* app launch) is a device-side fault whose text is the diagnosis, so it
|
|
21
23
|
* stays `ENGINE_FAILURE` and keeps its message and hint.
|
package/dist/errors.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* | anything else, agent-device's own timeouts included | ENGINE_FAILURE with the text |
|
|
15
15
|
*
|
|
16
16
|
* `OPERATION_TIMEOUT` is never produced here. In the contract it means the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* operation budget expired: `InputBudget` (support.ts) reports it for an input
|
|
18
|
+
* the budget ran out before, and `ACTION_MAY_HAVE_COMMITTED` for one sent that
|
|
19
|
+
* did not answer in time. Core replaces the timeout's message with a bare
|
|
20
|
+
* `operation timed out`. A timeout
|
|
19
21
|
* agent-device itself reports (a simulator boot, the runner connecting, an
|
|
20
22
|
* app launch) is a device-side fault whose text is the diagnosis, so it
|
|
21
23
|
* stays `ENGINE_FAILURE` and keeps its message and hint.
|
package/dist/support.d.ts
CHANGED
|
@@ -12,6 +12,34 @@ export interface Rect {
|
|
|
12
12
|
readonly height: number;
|
|
13
13
|
}
|
|
14
14
|
export declare function cancelled(text: string): EngineError;
|
|
15
|
+
/**
|
|
16
|
+
* One input operation's budget (`actionTimeout`, or the call's `timeout`),
|
|
17
|
+
* which the harness signal does not enforce. Only the device commands that
|
|
18
|
+
* send input run under it; reads stay unbounded, so a locator poll keeps its
|
|
19
|
+
* last snapshot and a target settling before a tap is relocated in full.
|
|
20
|
+
*/
|
|
21
|
+
export declare class InputBudget {
|
|
22
|
+
private readonly label;
|
|
23
|
+
private readonly track;
|
|
24
|
+
private readonly endsAt;
|
|
25
|
+
private sent;
|
|
26
|
+
/**
|
|
27
|
+
* `track` keeps the device command itself in flight, past a timeout that
|
|
28
|
+
* abandons it, until it settles. The callers' own tracking settles with the
|
|
29
|
+
* race, so this is the registration the next attempt waits on.
|
|
30
|
+
*/
|
|
31
|
+
constructor(label: string, timeoutMs: number, track: <T>(pending: Promise<T>) => Promise<T>);
|
|
32
|
+
/**
|
|
33
|
+
* Sends one input with what is left of the budget. With none left it sends
|
|
34
|
+
* nothing: `OPERATION_TIMEOUT`, or `ACTION_MAY_HAVE_COMMITTED` once an
|
|
35
|
+
* earlier input of this operation went out. A gesture that holds (`holdMs`)
|
|
36
|
+
* at least as long as the budget left could only be cut off mid-gesture, so
|
|
37
|
+
* it is refused unsent. An input that does not answer in time may have
|
|
38
|
+
* landed, so it is `ACTION_MAY_HAVE_COMMITTED` and the runner never repeats it.
|
|
39
|
+
*/
|
|
40
|
+
send<T>(dispatch: () => Promise<T>, holdMs?: number): Promise<T>;
|
|
41
|
+
private maybeCommitted;
|
|
42
|
+
}
|
|
15
43
|
export declare function invalidState(text: string): EngineError;
|
|
16
44
|
export declare function notActionable(text: string): EngineError;
|
|
17
45
|
export declare function unsupported(text: string): EngineError;
|
package/dist/support.js
CHANGED
|
@@ -1,8 +1,60 @@
|
|
|
1
1
|
/** Shared helpers for the agent-device engine: error constructors, filenames, PNG headers, gestures, the screen location and size. */
|
|
2
|
-
import { EngineError } from 'e2e/engine';
|
|
2
|
+
import { EngineError, withTimeout } from 'e2e/engine';
|
|
3
3
|
export function cancelled(text) {
|
|
4
4
|
return new EngineError('CANCELLED', text, { retryable: false });
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* The least time an input gets to answer once sent, however little budget
|
|
8
|
+
* was left: a tap that lands is then not reported as cut off.
|
|
9
|
+
*/
|
|
10
|
+
const MIN_ANSWER_MS = 250;
|
|
11
|
+
/**
|
|
12
|
+
* One input operation's budget (`actionTimeout`, or the call's `timeout`),
|
|
13
|
+
* which the harness signal does not enforce. Only the device commands that
|
|
14
|
+
* send input run under it; reads stay unbounded, so a locator poll keeps its
|
|
15
|
+
* last snapshot and a target settling before a tap is relocated in full.
|
|
16
|
+
*/
|
|
17
|
+
export class InputBudget {
|
|
18
|
+
label;
|
|
19
|
+
track;
|
|
20
|
+
endsAt;
|
|
21
|
+
sent = false;
|
|
22
|
+
/**
|
|
23
|
+
* `track` keeps the device command itself in flight, past a timeout that
|
|
24
|
+
* abandons it, until it settles. The callers' own tracking settles with the
|
|
25
|
+
* race, so this is the registration the next attempt waits on.
|
|
26
|
+
*/
|
|
27
|
+
constructor(label, timeoutMs, track) {
|
|
28
|
+
this.label = label;
|
|
29
|
+
this.track = track;
|
|
30
|
+
this.endsAt = Date.now() + timeoutMs;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Sends one input with what is left of the budget. With none left it sends
|
|
34
|
+
* nothing: `OPERATION_TIMEOUT`, or `ACTION_MAY_HAVE_COMMITTED` once an
|
|
35
|
+
* earlier input of this operation went out. A gesture that holds (`holdMs`)
|
|
36
|
+
* at least as long as the budget left could only be cut off mid-gesture, so
|
|
37
|
+
* it is refused unsent. An input that does not answer in time may have
|
|
38
|
+
* landed, so it is `ACTION_MAY_HAVE_COMMITTED` and the runner never repeats it.
|
|
39
|
+
*/
|
|
40
|
+
send(dispatch, holdMs = 0) {
|
|
41
|
+
const remaining = this.endsAt - Date.now();
|
|
42
|
+
if (remaining <= 0) {
|
|
43
|
+
return Promise.reject(this.sent ? this.maybeCommitted() : new EngineError('OPERATION_TIMEOUT', `${this.label} timed out before it was sent`, { retryable: false }));
|
|
44
|
+
}
|
|
45
|
+
if (holdMs >= remaining) {
|
|
46
|
+
return Promise.reject(new EngineError('NOT_ACTIONABLE', `${this.label} holds for ${holdMs} ms, but only ${Math.floor(remaining)} ms of its timeout are left; raise the timeout or shorten the hold`, { retryable: false }));
|
|
47
|
+
}
|
|
48
|
+
const pending = this.track(dispatch());
|
|
49
|
+
this.sent = true;
|
|
50
|
+
return withTimeout(pending, Math.max(remaining, MIN_ANSWER_MS), () => this.maybeCommitted());
|
|
51
|
+
}
|
|
52
|
+
maybeCommitted() {
|
|
53
|
+
return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${this.label} timed out before the device answered; its input may have been dispatched`, {
|
|
54
|
+
retryable: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
6
58
|
export function invalidState(text) {
|
|
7
59
|
return new EngineError('INVALID_STATE', text, { retryable: false });
|
|
8
60
|
}
|
package/dist/surface.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { type EngineAttemptContext, type EngineCleanupContext, type EngineInitInfo, type EngineObserveOptions, type EngineSnapshot, type LocatorAction, type LocatorExpression, type NodeRef, type OperationContext, type PointerAction, type VideoSegment, type SemanticNode, type ViewportPoint } from 'e2e/engine';
|
|
15
15
|
import { type AgentDeviceClient, type ClientFactory, type DevicePermission, type LaunchPermissions, type MobileOptions, type PermissionState } from './options.ts';
|
|
16
16
|
import { DevicePool } from './pool.ts';
|
|
17
|
+
import { InputBudget } from './support.ts';
|
|
17
18
|
/** Hinge poses `device.fold` accepts: `closed` lights the outer display, `half-open` and `open` the inner one. */
|
|
18
19
|
export type FoldPose = 'closed' | 'half-open' | 'open';
|
|
19
20
|
/** How `installApp` puts a build on the device. */
|
|
@@ -138,6 +139,8 @@ export declare class AgentDeviceSurface {
|
|
|
138
139
|
* not yet arrived.
|
|
139
140
|
*/
|
|
140
141
|
screenCommand<T>(label: string, run: (client: AgentDeviceClient) => Promise<T>, signal: AbortSignal): Promise<T>;
|
|
142
|
+
/** The budget one input runs under; its device commands stay in flight until they settle. */
|
|
143
|
+
inputBudget(label: string, timeoutMs: number): InputBudget;
|
|
141
144
|
/** Registers one device command as in flight until it settles. */
|
|
142
145
|
private track;
|
|
143
146
|
/**
|
|
@@ -313,7 +316,7 @@ export declare class AgentDeviceSurface {
|
|
|
313
316
|
* return, since the input lands late in the command's own round trip.
|
|
314
317
|
*/
|
|
315
318
|
private markAction;
|
|
316
|
-
perform(ref: NodeRef, action: LocatorAction, operation: OperationContext): Promise<void>;
|
|
319
|
+
perform(ref: NodeRef, action: LocatorAction, operation: OperationContext, input: InputBudget): Promise<void>;
|
|
317
320
|
/**
|
|
318
321
|
* Keys on a touch surface, in the contract's key grammar: `Enter` submits
|
|
319
322
|
* through the soft keyboard, `Space` and any single character are typed
|
|
@@ -327,7 +330,7 @@ export declare class AgentDeviceSurface {
|
|
|
327
330
|
* node's bounds are in, with no element resolved behind it. `settle` waits
|
|
328
331
|
* for the UI to go quiet, as the node taps do.
|
|
329
332
|
*/
|
|
330
|
-
performAt(point: ViewportPoint, action: PointerAction, operation: OperationContext): Promise<void>;
|
|
333
|
+
performAt(point: ViewportPoint, action: PointerAction, operation: OperationContext, input: InputBudget): Promise<void>;
|
|
331
334
|
/**
|
|
332
335
|
* Types into whatever holds focus through the device's text-input path;
|
|
333
336
|
* agent-device's `type` lands on the focused field. `replace` is not a
|
|
@@ -336,13 +339,13 @@ export declare class AgentDeviceSurface {
|
|
|
336
339
|
*/
|
|
337
340
|
typeText(text: string, options: {
|
|
338
341
|
readonly replace: boolean;
|
|
339
|
-
}, operation: OperationContext): Promise<void>;
|
|
342
|
+
}, operation: OperationContext, input: InputBudget): Promise<void>;
|
|
340
343
|
/**
|
|
341
344
|
* One key to the focused field, with the same reach as a node press: Enter
|
|
342
345
|
* submits through the soft keyboard, Space and single characters are typed.
|
|
343
346
|
* Modifiers and the other named keys have no event bus to land on.
|
|
344
347
|
*/
|
|
345
|
-
pressFocusedKey(key: string, operation: OperationContext): Promise<void>;
|
|
348
|
+
pressFocusedKey(key: string, operation: OperationContext, input: InputBudget): Promise<void>;
|
|
346
349
|
/**
|
|
347
350
|
* Hides the soft keyboard, so a control it covered can be reached.
|
|
348
351
|
* agent-device presses the keyboard's own dismiss key and refuses when the
|
|
@@ -351,7 +354,7 @@ export declare class AgentDeviceSurface {
|
|
|
351
354
|
* makes that guess, the way a user does and Maestro's `hideKeyboard` does,
|
|
352
355
|
* in {@link dismissKeyboardByGesture}.
|
|
353
356
|
*/
|
|
354
|
-
dismissKeyboard(signal: AbortSignal): Promise<void>;
|
|
357
|
+
dismissKeyboard(signal: AbortSignal, input?: InputBudget): Promise<void>;
|
|
355
358
|
/**
|
|
356
359
|
* A keyboard with no dismiss key goes the way a user sends it away: a short
|
|
357
360
|
* drag at the centre of the screen, which a scroll view turns into a
|
|
@@ -378,7 +381,7 @@ export declare class AgentDeviceSurface {
|
|
|
378
381
|
* press, or the one given when no tip is showing.
|
|
379
382
|
*/
|
|
380
383
|
private dismissKeyboardTip;
|
|
381
|
-
back(operation: OperationContext): Promise<void>;
|
|
384
|
+
back(operation: OperationContext, input: InputBudget): Promise<void>;
|
|
382
385
|
restart(operation: OperationContext): Promise<void>;
|
|
383
386
|
/** Clears the pinned app's persisted state and relaunches it: the device equivalent of a fresh context. */
|
|
384
387
|
reset(operation: OperationContext): Promise<void>;
|
package/dist/surface.js
CHANGED
|
@@ -25,7 +25,7 @@ import { deviceLabel, pinnedApp } from './bindings.js';
|
|
|
25
25
|
import { assertAppId } from './links.js';
|
|
26
26
|
import { DevicePool, deviceSelection } from './pool.js';
|
|
27
27
|
import { recordLease, travelledLease } from './provider.js';
|
|
28
|
-
import { invalidState, notActionable, logicalScreenSize, readPngSize, sanitizeFilename, screenLocation, swipeWithin, unsupported, } from './support.js';
|
|
28
|
+
import { invalidState, notActionable, logicalScreenSize, readPngSize, sanitizeFilename, screenLocation, swipeWithin, unsupported, InputBudget, } from './support.js';
|
|
29
29
|
/** Why nothing is pinned with `appPath` alone: the install is the suite's. */
|
|
30
30
|
const UNINSTALLED_BUILD = "the build the target's `app.appPath` names installed first with `device.installApp()`";
|
|
31
31
|
/** Platform element types that are the soft keyboard or one of its keys, as agent-device names them. */
|
|
@@ -99,6 +99,10 @@ function depthBelow(entry, ancestor) {
|
|
|
99
99
|
}
|
|
100
100
|
return hops;
|
|
101
101
|
}
|
|
102
|
+
/** Sends an input under its operation's budget, or directly from a fixture call, which has none. */
|
|
103
|
+
function sendInput(input, dispatch) {
|
|
104
|
+
return input === undefined ? dispatch() : input.send(dispatch);
|
|
105
|
+
}
|
|
102
106
|
function sleep(ms, signal) {
|
|
103
107
|
return new Promise((resolve) => {
|
|
104
108
|
const timer = setTimeout(done, ms);
|
|
@@ -276,6 +280,10 @@ export class AgentDeviceSurface {
|
|
|
276
280
|
this.markAction(before);
|
|
277
281
|
return result;
|
|
278
282
|
}
|
|
283
|
+
/** The budget one input runs under; its device commands stay in flight until they settle. */
|
|
284
|
+
inputBudget(label, timeoutMs) {
|
|
285
|
+
return new InputBudget(label, timeoutMs, (pending) => this.track(pending));
|
|
286
|
+
}
|
|
279
287
|
/** Registers one device command as in flight until it settles. */
|
|
280
288
|
track(pending) {
|
|
281
289
|
this.inflight.add(pending);
|
|
@@ -853,7 +861,7 @@ export class AgentDeviceSurface {
|
|
|
853
861
|
this.indexBeforeAction = before;
|
|
854
862
|
this.lastActionAt = Date.now();
|
|
855
863
|
}
|
|
856
|
-
async perform(ref, action, operation) {
|
|
864
|
+
async perform(ref, action, operation, input) {
|
|
857
865
|
// A test's step verifies its outcome with `expect`, so it never waits for
|
|
858
866
|
// the screen to settle afterwards; it only lets a control that came with
|
|
859
867
|
// the last action finish arriving. The agent reads the screen right after
|
|
@@ -872,7 +880,7 @@ export class AgentDeviceSurface {
|
|
|
872
880
|
if (action.kind !== 'swipe')
|
|
873
881
|
throw notActionable(`the screen root takes swipe only, not ${action.kind}; act on a node`);
|
|
874
882
|
const before = this.latestIndex;
|
|
875
|
-
await this.command('swipe', (client) => client.interactions.scroll({ direction: action.direction, ...settle }), operation.signal);
|
|
883
|
+
await this.command('swipe', (client) => input.send(() => client.interactions.scroll({ direction: action.direction, ...settle })), operation.signal);
|
|
876
884
|
this.markAction(before);
|
|
877
885
|
return;
|
|
878
886
|
}
|
|
@@ -889,26 +897,26 @@ export class AgentDeviceSurface {
|
|
|
889
897
|
const target = deterministic ? await this.settled(entry, operation) : entry;
|
|
890
898
|
switch (action.kind) {
|
|
891
899
|
case 'tap':
|
|
892
|
-
return client.interactions.press({ ...this.actionTarget(target, true), ...settle });
|
|
900
|
+
return input.send(() => client.interactions.press({ ...this.actionTarget(target, true), ...settle }));
|
|
893
901
|
case 'focus':
|
|
894
902
|
// A touch surface focuses by tapping, and a tap on anything but an
|
|
895
903
|
// editable field activates it; focus is offered for fields only.
|
|
896
904
|
if (target.node.role !== 'textbox') {
|
|
897
905
|
throw unsupported(`agent-device can only focus editable fields; node ${target.id} is ${target.node.role ?? 'unknown'}`);
|
|
898
906
|
}
|
|
899
|
-
return client.interactions.press({ ...this.actionTarget(target), ...settle });
|
|
907
|
+
return input.send(() => client.interactions.press({ ...this.actionTarget(target), ...settle }));
|
|
900
908
|
case 'doubleTap':
|
|
901
|
-
return client.interactions.press({ ...this.actionTarget(target), count: 2, ...settle });
|
|
909
|
+
return input.send(() => client.interactions.press({ ...this.actionTarget(target), count: 2, ...settle }));
|
|
902
910
|
case 'longPress':
|
|
903
|
-
return client.interactions.longPress({ ...this.actionTarget(target), ...settle, durationMs: action.durationMs ?? DEFAULT_LONG_PRESS_MS });
|
|
911
|
+
return input.send(() => client.interactions.longPress({ ...this.actionTarget(target), ...settle, durationMs: action.durationMs ?? DEFAULT_LONG_PRESS_MS }), action.durationMs ?? DEFAULT_LONG_PRESS_MS);
|
|
904
912
|
case 'hover':
|
|
905
|
-
return client.interactions.hover(this.actionTarget(target));
|
|
913
|
+
return input.send(() => client.interactions.hover(this.actionTarget(target)));
|
|
906
914
|
case 'fill':
|
|
907
915
|
// oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- agent-device fill, not Array#fill
|
|
908
|
-
return client.interactions.fill({ ...this.actionTarget(target), text: action.value, ...settle });
|
|
916
|
+
return input.send(() => client.interactions.fill({ ...this.actionTarget(target), text: action.value, ...settle }));
|
|
909
917
|
case 'clear':
|
|
910
918
|
// oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- agent-device fill, not Array#fill
|
|
911
|
-
return client.interactions.fill({ ...this.actionTarget(target), text: '', ...settle });
|
|
919
|
+
return input.send(() => client.interactions.fill({ ...this.actionTarget(target), text: '', ...settle }));
|
|
912
920
|
case 'check':
|
|
913
921
|
case 'uncheck': {
|
|
914
922
|
const wanted = action.kind === 'check';
|
|
@@ -920,22 +928,22 @@ export class AgentDeviceSurface {
|
|
|
920
928
|
}
|
|
921
929
|
if (checked === wanted)
|
|
922
930
|
return undefined;
|
|
923
|
-
return client.interactions.press({ ...this.actionTarget(target, true), ...settle });
|
|
931
|
+
return input.send(() => client.interactions.press({ ...this.actionTarget(target, true), ...settle }));
|
|
924
932
|
}
|
|
925
933
|
case 'press':
|
|
926
|
-
return this.pressKey(client, target, action.key, settle);
|
|
934
|
+
return this.pressKey(client, target, action.key, settle, input);
|
|
927
935
|
case 'swipe': {
|
|
928
936
|
const rect = target.node.rect;
|
|
929
937
|
if (rect === undefined)
|
|
930
938
|
throw notActionable(`node ${target.id} has no bounds to swipe within`);
|
|
931
|
-
return client.interactions.swipe(swipeWithin(rect, action.direction, action.momentum));
|
|
939
|
+
return input.send(() => client.interactions.swipe(swipeWithin(rect, action.direction, action.momentum)));
|
|
932
940
|
}
|
|
933
941
|
case 'dragTo': {
|
|
934
942
|
const destination = this.resolveRef(action.target);
|
|
935
|
-
return client.interactions.drag({
|
|
943
|
+
return input.send(() => client.interactions.drag({
|
|
936
944
|
source: this.actionTarget(target).ref,
|
|
937
945
|
destination: this.actionTarget(destination).ref,
|
|
938
|
-
});
|
|
946
|
+
}));
|
|
939
947
|
}
|
|
940
948
|
case 'scrollIntoView':
|
|
941
949
|
case 'selectOption':
|
|
@@ -959,7 +967,7 @@ export class AgentDeviceSurface {
|
|
|
959
967
|
* modifiers and the other named keys (`Escape`, `Tab`, `Backspace`, the
|
|
960
968
|
* arrows) have nothing to land on and are refused.
|
|
961
969
|
*/
|
|
962
|
-
async pressKey(client, entry, key, settle) {
|
|
970
|
+
async pressKey(client, entry, key, settle, input) {
|
|
963
971
|
const parsed = parseKey(key);
|
|
964
972
|
if (parsed === undefined) {
|
|
965
973
|
throw unsupported(`"${key}" is not a key: press takes one key in the form [Modifier+]...Key, a named key (${KEY_NAMES.join(', ')}) or one character`);
|
|
@@ -968,14 +976,14 @@ export class AgentDeviceSurface {
|
|
|
968
976
|
throw unsupported(`agent-device cannot hold ${parsed.modifiers.join('+')} on a device surface; press the key alone`);
|
|
969
977
|
}
|
|
970
978
|
if (parsed.key.kind === 'named' && parsed.key.name === 'Enter') {
|
|
971
|
-
return client.command.keyboard({ action: 'enter' });
|
|
979
|
+
return input.send(() => client.command.keyboard({ action: 'enter' }));
|
|
972
980
|
}
|
|
973
981
|
if (parsed.key.kind === 'char' || parsed.key.name === 'Space') {
|
|
974
982
|
const text = parsed.key.kind === 'char' ? parsed.key.char : ' ';
|
|
975
983
|
if (entry.node.role === 'textbox' && entry.node.states?.focused !== true) {
|
|
976
|
-
await client.interactions.press({ ...this.actionTarget(entry), ...settle });
|
|
984
|
+
await input.send(() => client.interactions.press({ ...this.actionTarget(entry), ...settle }));
|
|
977
985
|
}
|
|
978
|
-
return client.interactions.type({ text });
|
|
986
|
+
return input.send(() => client.interactions.type({ text }));
|
|
979
987
|
}
|
|
980
988
|
throw unsupported(`agent-device cannot press ${parsed.key.name} on a device surface; only Enter, Space, and single characters reach the soft keyboard`);
|
|
981
989
|
}
|
|
@@ -984,10 +992,11 @@ export class AgentDeviceSurface {
|
|
|
984
992
|
* node's bounds are in, with no element resolved behind it. `settle` waits
|
|
985
993
|
* for the UI to go quiet, as the node taps do.
|
|
986
994
|
*/
|
|
987
|
-
async performAt(point, action, operation) {
|
|
995
|
+
async performAt(point, action, operation, input) {
|
|
988
996
|
const settle = operation.origin === 'test' ? {} : this.settleOptions;
|
|
989
997
|
const before = this.latestIndex;
|
|
990
|
-
|
|
998
|
+
const holdMs = action.kind === 'longPress' ? (action.durationMs ?? DEFAULT_LONG_PRESS_MS) : action.kind === 'swipeTo' ? action.durationMs : undefined;
|
|
999
|
+
await this.command(`${action.kind} at point`, (client) => input.send(() => pointerInteraction(client, point, action, settle), holdMs), operation.signal);
|
|
991
1000
|
this.markAction(before);
|
|
992
1001
|
}
|
|
993
1002
|
/**
|
|
@@ -996,12 +1005,12 @@ export class AgentDeviceSurface {
|
|
|
996
1005
|
* device primitive without a target, so it is refused rather than faked:
|
|
997
1006
|
* the model clears a listed field by filling it by id.
|
|
998
1007
|
*/
|
|
999
|
-
async typeText(text, options, operation) {
|
|
1008
|
+
async typeText(text, options, operation, input) {
|
|
1000
1009
|
if (options.replace) {
|
|
1001
1010
|
throw unsupported('agent-device cannot clear the focused field without a node; type into a listed field by id to replace its value');
|
|
1002
1011
|
}
|
|
1003
1012
|
const before = this.latestIndex;
|
|
1004
|
-
await this.command('keyboard.type', (client) => client.interactions.type({ text }), operation.signal);
|
|
1013
|
+
await this.command('keyboard.type', (client) => input.send(() => client.interactions.type({ text })), operation.signal);
|
|
1005
1014
|
this.markAction(before);
|
|
1006
1015
|
}
|
|
1007
1016
|
/**
|
|
@@ -1009,7 +1018,7 @@ export class AgentDeviceSurface {
|
|
|
1009
1018
|
* submits through the soft keyboard, Space and single characters are typed.
|
|
1010
1019
|
* Modifiers and the other named keys have no event bus to land on.
|
|
1011
1020
|
*/
|
|
1012
|
-
async pressFocusedKey(key, operation) {
|
|
1021
|
+
async pressFocusedKey(key, operation, input) {
|
|
1013
1022
|
const parsed = parseKey(key);
|
|
1014
1023
|
if (parsed === undefined) {
|
|
1015
1024
|
throw unsupported(`"${key}" is not a key: press takes one key in the form [Modifier+]...Key, a named key (${KEY_NAMES.join(', ')}) or one character`);
|
|
@@ -1019,11 +1028,11 @@ export class AgentDeviceSurface {
|
|
|
1019
1028
|
}
|
|
1020
1029
|
const before = this.latestIndex;
|
|
1021
1030
|
if (parsed.key.kind === 'named' && parsed.key.name === 'Enter') {
|
|
1022
|
-
await this.command('keyboard.press', (client) => client.command.keyboard({ action: 'enter' }), operation.signal);
|
|
1031
|
+
await this.command('keyboard.press', (client) => input.send(() => client.command.keyboard({ action: 'enter' })), operation.signal);
|
|
1023
1032
|
}
|
|
1024
1033
|
else if (parsed.key.kind === 'char' || parsed.key.name === 'Space') {
|
|
1025
1034
|
const text = parsed.key.kind === 'char' ? parsed.key.char : ' ';
|
|
1026
|
-
await this.command('keyboard.press', (client) => client.interactions.type({ text }), operation.signal);
|
|
1035
|
+
await this.command('keyboard.press', (client) => input.send(() => client.interactions.type({ text })), operation.signal);
|
|
1027
1036
|
}
|
|
1028
1037
|
else {
|
|
1029
1038
|
throw unsupported(`agent-device cannot press ${parsed.key.name} on a device surface; only Enter, Space, and single characters reach the soft keyboard`);
|
|
@@ -1038,15 +1047,15 @@ export class AgentDeviceSurface {
|
|
|
1038
1047
|
* makes that guess, the way a user does and Maestro's `hideKeyboard` does,
|
|
1039
1048
|
* in {@link dismissKeyboardByGesture}.
|
|
1040
1049
|
*/
|
|
1041
|
-
async dismissKeyboard(signal) {
|
|
1050
|
+
async dismissKeyboard(signal, input) {
|
|
1042
1051
|
const before = this.latestIndex;
|
|
1043
1052
|
try {
|
|
1044
|
-
await this.command('keyboard.dismiss', (client) => client.command.keyboard({ action: 'dismiss' }), signal);
|
|
1053
|
+
await this.command('keyboard.dismiss', (client) => sendInput(input, () => client.command.keyboard({ action: 'dismiss' })), signal);
|
|
1045
1054
|
}
|
|
1046
1055
|
catch (cause) {
|
|
1047
1056
|
if (!(cause instanceof EngineError && cause.code === 'UNSUPPORTED_CAPABILITY'))
|
|
1048
1057
|
throw cause;
|
|
1049
|
-
await this.dismissKeyboardByGesture(signal);
|
|
1058
|
+
await this.dismissKeyboardByGesture(signal, input);
|
|
1050
1059
|
}
|
|
1051
1060
|
this.markAction(before);
|
|
1052
1061
|
}
|
|
@@ -1068,8 +1077,8 @@ export class AgentDeviceSurface {
|
|
|
1068
1077
|
* look is the observation's own capture, sparse-tree retries included: a
|
|
1069
1078
|
* one-node tree has no keys and would pass for a dismissed keyboard.
|
|
1070
1079
|
*/
|
|
1071
|
-
async dismissKeyboardByGesture(signal) {
|
|
1072
|
-
let raw = await this.dismissKeyboardTip(await this.snapshot(signal, false), signal);
|
|
1080
|
+
async dismissKeyboardByGesture(signal, input) {
|
|
1081
|
+
let raw = await this.dismissKeyboardTip(await this.snapshot(signal, false), signal, input);
|
|
1073
1082
|
if (!keyboardShowing(raw))
|
|
1074
1083
|
return;
|
|
1075
1084
|
const projected = projectSnapshot(raw.nodes ?? [], { mintId: () => 'keyboard', viewport: raw.viewport });
|
|
@@ -1080,7 +1089,7 @@ export class AgentDeviceSurface {
|
|
|
1080
1089
|
{ x: centre.x, y: centre.y - viewport.height * KEYBOARD_DRAG_RATIO },
|
|
1081
1090
|
];
|
|
1082
1091
|
for (const to of drags) {
|
|
1083
|
-
await this.command('keyboard.dismiss', (client) => client.interactions.swipe({ from: centre, to }), signal);
|
|
1092
|
+
await this.command('keyboard.dismiss', (client) => sendInput(input, () => client.interactions.swipe({ from: centre, to })), signal);
|
|
1084
1093
|
raw = await this.snapshot(signal, false);
|
|
1085
1094
|
if (!keyboardShowing(raw))
|
|
1086
1095
|
return;
|
|
@@ -1093,7 +1102,7 @@ export class AgentDeviceSurface {
|
|
|
1093
1102
|
* Maestro's `hideKeyboard` presses it too. Answers the capture after the
|
|
1094
1103
|
* press, or the one given when no tip is showing.
|
|
1095
1104
|
*/
|
|
1096
|
-
async dismissKeyboardTip(raw, signal) {
|
|
1105
|
+
async dismissKeyboardTip(raw, signal, input) {
|
|
1097
1106
|
const nodes = raw.nodes ?? [];
|
|
1098
1107
|
const tip = nodes.find((node) => (node.label ?? node.value ?? '').startsWith('Speed up your typing'));
|
|
1099
1108
|
if (tip?.rect === undefined)
|
|
@@ -1102,13 +1111,13 @@ export class AgentDeviceSurface {
|
|
|
1102
1111
|
const button = nodes.find((node) => node.label === 'Continue' && node.rect !== undefined && node.rect.y > tipTop);
|
|
1103
1112
|
if (button?.rect === undefined)
|
|
1104
1113
|
return raw;
|
|
1105
|
-
await this.command('keyboard.dismiss', (client) => client.interactions.press(centreOf(button.rect)), signal);
|
|
1114
|
+
await this.command('keyboard.dismiss', (client) => sendInput(input, () => client.interactions.press(centreOf(button.rect))), signal);
|
|
1106
1115
|
return await this.snapshot(signal, false);
|
|
1107
1116
|
}
|
|
1108
|
-
async back(operation) {
|
|
1117
|
+
async back(operation, input) {
|
|
1109
1118
|
const settle = operation.origin === 'test' ? {} : this.settleOptions;
|
|
1110
1119
|
const before = this.latestIndex;
|
|
1111
|
-
await this.command('back', (client) => client.command.back({ ...settle }), operation.signal);
|
|
1120
|
+
await this.command('back', (client) => input.send(() => client.command.back({ ...settle })), operation.signal);
|
|
1112
1121
|
this.markAction(before);
|
|
1113
1122
|
}
|
|
1114
1123
|
async restart(operation) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@e2e-dev/mobile",
|
|
3
|
-
"version": "0.12.1
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Mobile engine (iOS and Android) for e2e agentic end-to-end testing, built on agent-device",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"e2e",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"ai": "^7.0.0",
|
|
60
|
-
"e2e": "0.
|
|
60
|
+
"e2e": ">=0.15.0 <1"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"ai": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@types/node": "26.6.2",
|
|
69
69
|
"@types/pngjs": "6.0.5",
|
|
70
70
|
"ai": "7.0.107",
|
|
71
|
-
"e2e": "0.21.0
|
|
71
|
+
"e2e": "0.21.0",
|
|
72
72
|
"typescript": "7.0.2",
|
|
73
73
|
"vitest": "5.0.1"
|
|
74
74
|
},
|