@autifyhq/aximo-cli 0.8.0 → 0.10.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/README.md +19 -7
- package/dist/client.d.ts +7 -1
- package/dist/client.js +21 -2
- package/dist/client.test.js +36 -0
- package/dist/commands/case/case.test.js +62 -0
- package/dist/commands/case/create.d.ts +1 -1
- package/dist/commands/case/create.js +4 -4
- package/dist/commands/case/index.js +1 -0
- package/dist/commands/case/update.d.ts +50 -0
- package/dist/commands/case/update.js +78 -0
- package/dist/commands/device/connect.js +19 -1
- package/dist/commands/device/doctor.d.ts +2 -0
- package/dist/commands/device/doctor.js +37 -10
- package/dist/commands/device/doctor.test.js +26 -1
- package/dist/commands/health/check.js +15 -3
- package/dist/commands/health/check.test.js +27 -3
- package/dist/commands/mcp/server.test.js +3 -1
- package/dist/commands/mcp/tools.js +28 -0
- package/dist/commands/mcp/tools.test.js +90 -0
- package/dist/commands/mobile/connect-core.d.ts +5 -3
- package/dist/commands/mobile/connect-core.js +60 -12
- package/dist/commands/mobile/connect.d.ts +5 -0
- package/dist/commands/mobile/connect.js +6 -1
- package/dist/commands/mobile/signing-consent.d.ts +18 -0
- package/dist/commands/mobile/signing-consent.js +22 -0
- package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
- package/dist/commands/mobile/signing-consent.test.js +50 -0
- package/dist/commands/plan/index.js +1 -0
- package/dist/commands/plan/plan.test.js +61 -0
- package/dist/commands/plan/update.d.ts +42 -0
- package/dist/commands/plan/update.js +78 -0
- package/dist/commands/schema.js +1 -1
- package/dist/commands/session/save-as-case.js +3 -3
- package/dist/commands/trigger/plan/run.d.ts +6 -1
- package/dist/commands/trigger/plan/run.js +9 -3
- package/dist/commands/trigger/trigger.test.js +22 -3
- package/dist/commands/upload/put.d.ts +5 -0
- package/dist/commands/upload/put.js +44 -11
- package/dist/commands/upload/upload.test.js +82 -0
- package/dist/desktop-server/preflight.d.ts +43 -0
- package/dist/desktop-server/preflight.js +78 -0
- package/dist/desktop-server/preflight.test.d.ts +1 -0
- package/dist/desktop-server/preflight.test.js +115 -0
- package/dist/mobile-control/appium-controller.d.ts +28 -0
- package/dist/mobile-control/appium-controller.js +271 -92
- package/dist/mobile-control/appium-controller.test.js +258 -8
- package/dist/mobile-control/bundle-id-map.js +3 -0
- package/dist/mobile-control/controller.d.ts +3 -0
- package/dist/mobile-control/frame-sender.d.ts +34 -0
- package/dist/mobile-control/frame-sender.js +96 -0
- package/dist/mobile-control/frame-sender.test.d.ts +1 -0
- package/dist/mobile-control/frame-sender.test.js +108 -0
- package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
- package/dist/mobile-control/mjpeg-stream.js +160 -0
- package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
- package/dist/mobile-control/mjpeg-stream.test.js +93 -0
- package/dist/mobile-control/tunnel.d.ts +10 -1
- package/dist/mobile-control/tunnel.js +43 -2
- package/dist/mobile-control/tunnel.test.js +108 -0
- package/dist/mobile-control/webdriver.d.ts +9 -1
- package/dist/mobile-control/webdriver.js +17 -5
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.js +4 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +19 -0
- package/dist/types.d.ts +66 -4
- package/dist/validate.d.ts +6 -1
- package/dist/validate.js +46 -0
- package/dist/validate.test.js +39 -1
- package/package.json +1 -1
|
@@ -12,6 +12,12 @@ export interface AppiumControllerOptions {
|
|
|
12
12
|
* isn't picked long before it is bound.
|
|
13
13
|
*/
|
|
14
14
|
wdaLocalPort?: number | (() => Promise<number>);
|
|
15
|
+
/**
|
|
16
|
+
* Local port the driver forwards to the on-device MJPEG server (WDA / the
|
|
17
|
+
* UiAutomator2 server). When set, the session is created with the MJPEG
|
|
18
|
+
* server enabled and conservative capture settings applied.
|
|
19
|
+
*/
|
|
20
|
+
mjpegServerPort?: number | (() => Promise<number>);
|
|
15
21
|
transport?: WebDriverTransport;
|
|
16
22
|
}
|
|
17
23
|
export declare class AppiumController implements MobileController {
|
|
@@ -20,12 +26,28 @@ export declare class AppiumController implements MobileController {
|
|
|
20
26
|
private readonly target;
|
|
21
27
|
private readonly iosSigning;
|
|
22
28
|
private readonly wdaLocalPort;
|
|
29
|
+
private readonly mjpegServerPort;
|
|
23
30
|
private resolvedWdaPort;
|
|
31
|
+
private resolvedMjpegPort;
|
|
24
32
|
private sessionReady;
|
|
25
33
|
private scaling;
|
|
26
34
|
constructor(opts: AppiumControllerOptions);
|
|
27
35
|
private capabilities;
|
|
28
36
|
private ensureSession;
|
|
37
|
+
private static readonly MJPEG_SETTINGS;
|
|
38
|
+
private applyMjpegSettings;
|
|
39
|
+
private resetSession;
|
|
40
|
+
/**
|
|
41
|
+
* Run a command against a live session, rebuilding the session once if the
|
|
42
|
+
* cached one has died (WDA killed for CPU, device rebooted, adb dropped).
|
|
43
|
+
* Without this, one on-device crash permanently wedges every later command —
|
|
44
|
+
* including the screenshot fallback — until the CLI is restarted.
|
|
45
|
+
*/
|
|
46
|
+
private withSession;
|
|
47
|
+
/** Bootstrap (or rebuild) the device session without running a command. */
|
|
48
|
+
ensureReady(): Promise<void>;
|
|
49
|
+
/** Local port of the driver-forwarded MJPEG server, once the session is up. */
|
|
50
|
+
getMjpegPort(): number | undefined;
|
|
29
51
|
private scale;
|
|
30
52
|
private toPoint;
|
|
31
53
|
private pointerAction;
|
|
@@ -34,14 +56,20 @@ export declare class AppiumController implements MobileController {
|
|
|
34
56
|
doubleTap(x: number, y: number): Promise<void>;
|
|
35
57
|
longPress(x: number, y: number, durationMs: number): Promise<void>;
|
|
36
58
|
swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
|
|
59
|
+
pinchZoom(x: number, y: number, scale: number, durationMs: number): Promise<void>;
|
|
60
|
+
private pinchZoomOnce;
|
|
37
61
|
private performPointer;
|
|
38
62
|
typeText(text: string): Promise<ActionResult>;
|
|
63
|
+
private activeElementOrNull;
|
|
39
64
|
clearText(): Promise<ActionResult>;
|
|
40
65
|
pressKey(key: string): Promise<ActionResult>;
|
|
66
|
+
private pressKeyOnce;
|
|
41
67
|
launchApp(app: string): Promise<ActionResult>;
|
|
42
68
|
findElement(text: string): Promise<ActionResult>;
|
|
43
69
|
private locate;
|
|
44
70
|
hideKeyboard(): Promise<ActionResult>;
|
|
71
|
+
rotateDevice(orientation: "portrait" | "landscape"): Promise<ActionResult>;
|
|
72
|
+
setLocation(latitude: number, longitude: number, altitude: number): Promise<ActionResult>;
|
|
45
73
|
getDeviceTime(): Promise<{
|
|
46
74
|
datetime: string;
|
|
47
75
|
timezone: string;
|
|
@@ -12,6 +12,8 @@ const ANDROID_KEY_CODES = {
|
|
|
12
12
|
};
|
|
13
13
|
const ANDROID_KEYCODE_A = 29;
|
|
14
14
|
const ANDROID_META_CTRL_ON = 4096;
|
|
15
|
+
// Chars that break Android's `mobile: type` (adb shell input text): shell metachars, non-ASCII.
|
|
16
|
+
const ANDROID_INPUT_TEXT_UNSAFE = /[^A-Za-z0-9 @:,./+_=-]/;
|
|
15
17
|
const IOS_BUTTONS = {
|
|
16
18
|
home: "home",
|
|
17
19
|
};
|
|
@@ -22,13 +24,22 @@ const W3C_KEYS = {
|
|
|
22
24
|
escape: "\uE00C",
|
|
23
25
|
space: " ",
|
|
24
26
|
};
|
|
27
|
+
// A dead-but-cached Appium session (WDA killed for CPU, phone rebooted, adb
|
|
28
|
+
// dropped) answers every command with one of these. Distinct from command
|
|
29
|
+
// failures on a live session, which must not trigger a session rebuild.
|
|
30
|
+
const SESSION_DEAD_PATTERN = /invalid session id|terminated or not started|session deleted|session not found|has already finished/i;
|
|
31
|
+
function isSessionDeadError(err) {
|
|
32
|
+
return err instanceof Error && SESSION_DEAD_PATTERN.test(err.message);
|
|
33
|
+
}
|
|
25
34
|
export class AppiumController {
|
|
26
35
|
session;
|
|
27
36
|
platform;
|
|
28
37
|
target;
|
|
29
38
|
iosSigning;
|
|
30
39
|
wdaLocalPort;
|
|
40
|
+
mjpegServerPort;
|
|
31
41
|
resolvedWdaPort;
|
|
42
|
+
resolvedMjpegPort;
|
|
32
43
|
sessionReady = null;
|
|
33
44
|
scaling = null;
|
|
34
45
|
constructor(opts) {
|
|
@@ -36,10 +47,14 @@ export class AppiumController {
|
|
|
36
47
|
this.target = opts.target;
|
|
37
48
|
this.iosSigning = opts.iosSigning;
|
|
38
49
|
this.wdaLocalPort = opts.wdaLocalPort;
|
|
50
|
+
this.mjpegServerPort = opts.mjpegServerPort;
|
|
39
51
|
const transport = opts.transport ?? fetchTransport(opts.endpoint);
|
|
40
52
|
this.session = new WebDriverSession(transport);
|
|
41
53
|
}
|
|
42
54
|
capabilities() {
|
|
55
|
+
const mjpeg = this.resolvedMjpegPort
|
|
56
|
+
? { "appium:mjpegServerPort": this.resolvedMjpegPort }
|
|
57
|
+
: {};
|
|
43
58
|
if (this.platform === "android") {
|
|
44
59
|
return {
|
|
45
60
|
platformName: "Android",
|
|
@@ -48,6 +63,7 @@ export class AppiumController {
|
|
|
48
63
|
"appium:noReset": true,
|
|
49
64
|
"appium:autoLaunch": false,
|
|
50
65
|
"appium:newCommandTimeout": 0,
|
|
66
|
+
...mjpeg,
|
|
51
67
|
};
|
|
52
68
|
}
|
|
53
69
|
return {
|
|
@@ -57,6 +73,7 @@ export class AppiumController {
|
|
|
57
73
|
"appium:newCommandTimeout": 0,
|
|
58
74
|
// Unique local->device WDA port so a second connected phone doesn't collide on 8100.
|
|
59
75
|
...(this.resolvedWdaPort ? { "appium:wdaLocalPort": this.resolvedWdaPort } : {}),
|
|
76
|
+
...mjpeg,
|
|
60
77
|
// WDA code-signing caps for physical iPhones (empty unless a team id is set).
|
|
61
78
|
...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
|
|
62
79
|
};
|
|
@@ -66,11 +83,14 @@ export class AppiumController {
|
|
|
66
83
|
this.sessionReady = (async () => {
|
|
67
84
|
this.resolvedWdaPort =
|
|
68
85
|
typeof this.wdaLocalPort === "function" ? await this.wdaLocalPort() : this.wdaLocalPort;
|
|
86
|
+
this.resolvedMjpegPort =
|
|
87
|
+
typeof this.mjpegServerPort === "function"
|
|
88
|
+
? await this.mjpegServerPort()
|
|
89
|
+
: this.mjpegServerPort;
|
|
69
90
|
await this.session.createSession(this.capabilities());
|
|
91
|
+
await this.applyMjpegSettings();
|
|
70
92
|
})().catch(async (err) => {
|
|
71
|
-
this.
|
|
72
|
-
// A retry must re-pick a free port — the one resolved here may be taken by then.
|
|
73
|
-
this.resolvedWdaPort = undefined;
|
|
93
|
+
this.resetSession();
|
|
74
94
|
// XCUITest reports session-start failures generically ("xcodebuild failed
|
|
75
95
|
// with code 65"); enrich with the real reason from the WDA .xcresult and
|
|
76
96
|
// an actionable hint so the user isn't left with an opaque error.
|
|
@@ -82,6 +102,61 @@ export class AppiumController {
|
|
|
82
102
|
}
|
|
83
103
|
return this.sessionReady;
|
|
84
104
|
}
|
|
105
|
+
// Conservative stream shape until runtime adaptation lands: iOS defaults to
|
|
106
|
+
// scalingFactor 100 (full-res frames) which can drive WDA CPU high enough for
|
|
107
|
+
// the OS to kill it, so both platforms are pinned to the same explicit values.
|
|
108
|
+
static MJPEG_SETTINGS = {
|
|
109
|
+
mjpegServerFramerate: 10,
|
|
110
|
+
mjpegScalingFactor: 50,
|
|
111
|
+
mjpegServerScreenshotQuality: 40,
|
|
112
|
+
};
|
|
113
|
+
async applyMjpegSettings() {
|
|
114
|
+
if (!this.resolvedMjpegPort)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
await this.session.updateSettings(AppiumController.MJPEG_SETTINGS);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error(`MJPEG capture settings were not applied (streaming continues with driver defaults): ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
resetSession() {
|
|
124
|
+
this.sessionReady = null;
|
|
125
|
+
this.scaling = null;
|
|
126
|
+
// A retry must re-pick free ports — the ones resolved here may be taken by then.
|
|
127
|
+
this.resolvedWdaPort = undefined;
|
|
128
|
+
this.resolvedMjpegPort = undefined;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Run a command against a live session, rebuilding the session once if the
|
|
132
|
+
* cached one has died (WDA killed for CPU, device rebooted, adb dropped).
|
|
133
|
+
* Without this, one on-device crash permanently wedges every later command —
|
|
134
|
+
* including the screenshot fallback — until the CLI is restarted.
|
|
135
|
+
*/
|
|
136
|
+
async withSession(fn) {
|
|
137
|
+
const ready = this.ensureSession();
|
|
138
|
+
await ready;
|
|
139
|
+
try {
|
|
140
|
+
return await fn();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (!isSessionDeadError(err))
|
|
144
|
+
throw err;
|
|
145
|
+
// Only reset if no concurrent command already rebuilt the session.
|
|
146
|
+
if (this.sessionReady === ready)
|
|
147
|
+
this.resetSession();
|
|
148
|
+
await this.ensureSession();
|
|
149
|
+
return await fn();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Bootstrap (or rebuild) the device session without running a command. */
|
|
153
|
+
async ensureReady() {
|
|
154
|
+
await this.ensureSession();
|
|
155
|
+
}
|
|
156
|
+
/** Local port of the driver-forwarded MJPEG server, once the session is up. */
|
|
157
|
+
getMjpegPort() {
|
|
158
|
+
return this.resolvedMjpegPort;
|
|
159
|
+
}
|
|
85
160
|
// Incoming pixel coords scale to logical points: iOS divides by DPR, Android ~= 1.
|
|
86
161
|
async scale() {
|
|
87
162
|
if (this.scaling)
|
|
@@ -107,104 +182,176 @@ export class AppiumController {
|
|
|
107
182
|
];
|
|
108
183
|
}
|
|
109
184
|
async screenshot() {
|
|
110
|
-
|
|
111
|
-
return this.session.screenshot();
|
|
185
|
+
return this.withSession(() => this.session.screenshot());
|
|
112
186
|
}
|
|
113
187
|
async tap(x, y) {
|
|
114
|
-
await this.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
188
|
+
await this.withSession(async () => {
|
|
189
|
+
const [px, py] = await this.toPoint(x, y);
|
|
190
|
+
await this.performPointer([
|
|
191
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
192
|
+
{ type: "pointerDown", button: 0 },
|
|
193
|
+
{ type: "pause", duration: 50 },
|
|
194
|
+
{ type: "pointerUp", button: 0 },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
122
197
|
}
|
|
123
198
|
async doubleTap(x, y) {
|
|
124
|
-
await this.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
199
|
+
await this.withSession(async () => {
|
|
200
|
+
const [px, py] = await this.toPoint(x, y);
|
|
201
|
+
await this.performPointer([
|
|
202
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
203
|
+
{ type: "pointerDown", button: 0 },
|
|
204
|
+
{ type: "pause", duration: 50 },
|
|
205
|
+
{ type: "pointerUp", button: 0 },
|
|
206
|
+
{ type: "pause", duration: 50 },
|
|
207
|
+
{ type: "pointerDown", button: 0 },
|
|
208
|
+
{ type: "pause", duration: 50 },
|
|
209
|
+
{ type: "pointerUp", button: 0 },
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
136
212
|
}
|
|
137
213
|
async longPress(x, y, durationMs) {
|
|
138
|
-
await this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
214
|
+
await this.withSession(async () => {
|
|
215
|
+
const [px, py] = await this.toPoint(x, y);
|
|
216
|
+
await this.performPointer([
|
|
217
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
218
|
+
{ type: "pointerDown", button: 0 },
|
|
219
|
+
{ type: "pause", duration: Math.max(0, Math.round(durationMs)) },
|
|
220
|
+
{ type: "pointerUp", button: 0 },
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
146
223
|
}
|
|
147
224
|
async swipe(x1, y1, x2, y2, durationMs) {
|
|
148
|
-
await this.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
225
|
+
await this.withSession(async () => {
|
|
226
|
+
const [px1, py1] = await this.toPoint(x1, y1);
|
|
227
|
+
const [px2, py2] = await this.toPoint(x2, y2);
|
|
228
|
+
await this.performPointer([
|
|
229
|
+
{ type: "pointerMove", duration: 0, x: px1, y: py1 },
|
|
230
|
+
{ type: "pointerDown", button: 0 },
|
|
231
|
+
{ type: "pointerMove", duration: Math.max(0, Math.round(durationMs)), x: px2, y: py2 },
|
|
232
|
+
{ type: "pointerUp", button: 0 },
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Two-finger vertical pinch: scale < 1 pinches in (zoom out), > 1 spreads (zoom in).
|
|
237
|
+
async pinchZoom(x, y, scale, durationMs) {
|
|
238
|
+
await this.withSession(() => this.pinchZoomOnce(x, y, scale, durationMs));
|
|
239
|
+
}
|
|
240
|
+
async pinchZoomOnce(x, y, scale, durationMs) {
|
|
241
|
+
const [cx, cy] = await this.toPoint(x, y);
|
|
242
|
+
const startOffset = 100;
|
|
243
|
+
const endOffset = startOffset * scale;
|
|
244
|
+
const duration = Math.max(0, Math.round(durationMs));
|
|
245
|
+
const finger = (id, startY, endY) => ({
|
|
246
|
+
type: "pointer",
|
|
247
|
+
id,
|
|
248
|
+
parameters: { pointerType: "touch" },
|
|
249
|
+
actions: [
|
|
250
|
+
{ type: "pointerMove", duration: 0, x: cx, y: Math.round(startY) },
|
|
251
|
+
{ type: "pointerDown", button: 0 },
|
|
252
|
+
{ type: "pause", duration: 50 },
|
|
253
|
+
{ type: "pointerMove", duration, x: cx, y: Math.round(endY) },
|
|
254
|
+
{ type: "pointerUp", button: 0 },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
await this.session.performActions([
|
|
258
|
+
finger("finger1", cy - startOffset / 2, cy - endOffset / 2),
|
|
259
|
+
finger("finger2", cy + startOffset / 2, cy + endOffset / 2),
|
|
156
260
|
]);
|
|
261
|
+
await this.session.releaseActions();
|
|
157
262
|
}
|
|
158
263
|
async performPointer(actions) {
|
|
159
264
|
await this.session.performActions(this.pointerAction(actions));
|
|
160
265
|
await this.session.releaseActions();
|
|
161
266
|
}
|
|
162
267
|
async typeText(text) {
|
|
163
|
-
await this.ensureSession();
|
|
164
268
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
269
|
+
return await this.withSession(async () => {
|
|
270
|
+
if (this.platform === "android") {
|
|
271
|
+
if (ANDROID_INPUT_TEXT_UNSAFE.test(text)) {
|
|
272
|
+
const active = await this.activeElementOrNull();
|
|
273
|
+
if (!active) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: "Cannot type special or non-ASCII characters: no input field is focused. Tap the field first.",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
await this.session.elementSendKeys(active, text);
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
await this.session.execute("mobile: type", [{ text }]);
|
|
283
|
+
return { ok: true };
|
|
284
|
+
}
|
|
285
|
+
// XCUITest: per-char W3C key actions; no focused element required.
|
|
286
|
+
const actions = [...text].flatMap((char) => [
|
|
287
|
+
{ type: "keyDown", value: char },
|
|
288
|
+
{ type: "keyUp", value: char },
|
|
289
|
+
]);
|
|
290
|
+
await this.session.performActions([{ type: "key", id: "keyboard", actions }]);
|
|
291
|
+
await this.session.releaseActions();
|
|
167
292
|
return { ok: true };
|
|
168
|
-
}
|
|
169
|
-
await this.session.setValueOnActiveElement(text);
|
|
170
|
-
return { ok: true };
|
|
293
|
+
});
|
|
171
294
|
}
|
|
172
295
|
catch (error) {
|
|
173
296
|
return fail(error);
|
|
174
297
|
}
|
|
175
298
|
}
|
|
299
|
+
// Some drivers report "no focus" by rejecting; treat that as null.
|
|
300
|
+
async activeElementOrNull() {
|
|
301
|
+
try {
|
|
302
|
+
return await this.session.getActiveElement();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
176
308
|
async clearText() {
|
|
177
|
-
await this.ensureSession();
|
|
178
309
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
310
|
+
return await this.withSession(async () => {
|
|
311
|
+
try {
|
|
312
|
+
const active = await this.session.getActiveElement();
|
|
313
|
+
if (active) {
|
|
314
|
+
await this.session.elementClear(active);
|
|
315
|
+
return { ok: true };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// Re-throw a dead session so withSession can rebuild it; "no focused
|
|
320
|
+
// element" style rejections fall through to the fallback path.
|
|
321
|
+
if (isSessionDeadError(error))
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
if (this.platform === "android") {
|
|
325
|
+
await this.session.execute("mobile: pressKey", [
|
|
326
|
+
{ keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
|
|
327
|
+
]);
|
|
328
|
+
await this.session.execute("mobile: pressKey", [
|
|
329
|
+
{ keycode: ANDROID_KEY_CODES["delete"] },
|
|
330
|
+
]);
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: "Could not clear the field: no focused input element was found. " +
|
|
336
|
+
"Tap the field to focus it before calling clear_text.",
|
|
337
|
+
};
|
|
338
|
+
});
|
|
184
339
|
}
|
|
185
|
-
catch {
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
await this.session.execute("mobile: pressKey", [
|
|
189
|
-
{ keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
|
|
190
|
-
]);
|
|
191
|
-
await this.session.execute("mobile: pressKey", [{ keycode: ANDROID_KEY_CODES["delete"] }]);
|
|
192
|
-
return { ok: true };
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
return fail(error);
|
|
196
|
-
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return fail(error);
|
|
197
342
|
}
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
error: "Could not clear the field: no focused input element was found. " +
|
|
201
|
-
"Tap the field to focus it before calling clear_text.",
|
|
202
|
-
};
|
|
203
343
|
}
|
|
204
344
|
async pressKey(key) {
|
|
205
|
-
await this.ensureSession();
|
|
206
345
|
const normalized = key.toLowerCase().trim();
|
|
207
346
|
try {
|
|
347
|
+
return await this.withSession(() => this.pressKeyOnce(key, normalized));
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
return fail(error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async pressKeyOnce(key, normalized) {
|
|
354
|
+
{
|
|
208
355
|
if (this.platform === "android") {
|
|
209
356
|
const code = ANDROID_KEY_CODES[normalized];
|
|
210
357
|
if (code === undefined)
|
|
@@ -240,18 +387,16 @@ export class AppiumController {
|
|
|
240
387
|
}
|
|
241
388
|
return { ok: false, error: `Unsupported key: ${key}` };
|
|
242
389
|
}
|
|
243
|
-
catch (error) {
|
|
244
|
-
return fail(error);
|
|
245
|
-
}
|
|
246
390
|
}
|
|
247
391
|
async launchApp(app) {
|
|
248
392
|
if (!app)
|
|
249
393
|
return { ok: false, error: "launch_app requires an app id" };
|
|
250
|
-
await this.ensureSession();
|
|
251
394
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
395
|
+
return await this.withSession(async () => {
|
|
396
|
+
const param = this.platform === "android" ? { appId: app } : { bundleId: app };
|
|
397
|
+
await this.session.execute("mobile: activateApp", [param]);
|
|
398
|
+
return { ok: true };
|
|
399
|
+
});
|
|
255
400
|
}
|
|
256
401
|
catch (error) {
|
|
257
402
|
return fail(error);
|
|
@@ -260,13 +405,14 @@ export class AppiumController {
|
|
|
260
405
|
async findElement(text) {
|
|
261
406
|
if (!text)
|
|
262
407
|
return { ok: false, error: "find_element requires text" };
|
|
263
|
-
await this.ensureSession();
|
|
264
408
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
409
|
+
return await this.withSession(async () => {
|
|
410
|
+
const ref = await this.locate(text);
|
|
411
|
+
if (!ref)
|
|
412
|
+
return { ok: false, error: `Element with text "${text}" not found` };
|
|
413
|
+
await this.session.elementClick(ref);
|
|
414
|
+
return { ok: true };
|
|
415
|
+
});
|
|
270
416
|
}
|
|
271
417
|
catch (error) {
|
|
272
418
|
return fail(error);
|
|
@@ -277,7 +423,8 @@ export class AppiumController {
|
|
|
277
423
|
for (const [using, value] of strategies) {
|
|
278
424
|
try {
|
|
279
425
|
const ref = await this.session.findElement(using, value);
|
|
280
|
-
|
|
426
|
+
// Skip hidden matches so the next strategy can find a visible one.
|
|
427
|
+
if (ref && (await this.session.elementDisplayed(ref)))
|
|
281
428
|
return ref;
|
|
282
429
|
}
|
|
283
430
|
catch { }
|
|
@@ -285,19 +432,51 @@ export class AppiumController {
|
|
|
285
432
|
return null;
|
|
286
433
|
}
|
|
287
434
|
async hideKeyboard() {
|
|
288
|
-
await this.ensureSession();
|
|
289
435
|
try {
|
|
290
|
-
await this.session.execute("mobile: hideKeyboard", []);
|
|
291
|
-
return { ok: true };
|
|
436
|
+
await this.withSession(() => this.session.execute("mobile: hideKeyboard", []));
|
|
292
437
|
}
|
|
293
|
-
catch {
|
|
294
|
-
|
|
438
|
+
catch { }
|
|
439
|
+
return { ok: true };
|
|
440
|
+
}
|
|
441
|
+
async rotateDevice(orientation) {
|
|
442
|
+
try {
|
|
443
|
+
return await this.withSession(async () => {
|
|
444
|
+
const wantLandscape = orientation === "landscape";
|
|
445
|
+
await this.session.setOrientation(wantLandscape ? "LANDSCAPE" : "PORTRAIT");
|
|
446
|
+
// Rotation changes the window rect, so the cached pixel->point scale is stale.
|
|
447
|
+
this.scaling = null;
|
|
448
|
+
// Poll up to 2s until the OS reports the new dimensions; rotation speed varies.
|
|
449
|
+
const deadline = Date.now() + 2000;
|
|
450
|
+
while (Date.now() < deadline) {
|
|
451
|
+
try {
|
|
452
|
+
const rect = await this.session.windowRect();
|
|
453
|
+
if (rect.width > rect.height === wantLandscape)
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
catch { }
|
|
457
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
458
|
+
}
|
|
459
|
+
return { ok: true };
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
return fail(error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async setLocation(latitude, longitude, altitude) {
|
|
467
|
+
try {
|
|
468
|
+
return await this.withSession(async () => {
|
|
469
|
+
await this.session.setGeoLocation({ latitude, longitude, altitude });
|
|
470
|
+
return { ok: true };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
return fail(error);
|
|
295
475
|
}
|
|
296
476
|
}
|
|
297
477
|
async getDeviceTime() {
|
|
298
|
-
await this.ensureSession();
|
|
299
478
|
try {
|
|
300
|
-
const raw = await this.session.execute("mobile: getDeviceTime", []);
|
|
479
|
+
const raw = await this.withSession(() => this.session.execute("mobile: getDeviceTime", []));
|
|
301
480
|
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
302
481
|
return null;
|
|
303
482
|
const parsed = new Date(raw);
|