@autifyhq/aximo-cli 0.9.0 → 0.10.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/README.md +1 -1
- 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/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 +2 -2
- package/dist/commands/mobile/connect-core.js +35 -2
- package/dist/commands/mobile/signing-consent.js +5 -11
- 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 +24 -0
- package/dist/mobile-control/appium-controller.js +221 -127
- package/dist/mobile-control/appium-controller.test.js +105 -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 +27 -3
- package/dist/mobile-control/tunnel.test.js +50 -0
- package/dist/mobile-control/webdriver.d.ts +2 -0
- package/dist/mobile-control/webdriver.js +6 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +19 -0
- package/dist/types.d.ts +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { confirmProceed, formatPreflightLines, hasBlockingChecks, runLocalPreflight, } from "./preflight.js";
|
|
6
|
+
const REPORT = {
|
|
7
|
+
agent: { platform: "Darwin" },
|
|
8
|
+
checks: [
|
|
9
|
+
{
|
|
10
|
+
name: "capture target",
|
|
11
|
+
status: "info",
|
|
12
|
+
message: "capturing the primary monitor (2560x1440)",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "accessibility permission",
|
|
16
|
+
status: "fail",
|
|
17
|
+
message: "not granted",
|
|
18
|
+
hint: "System Settings → Privacy & Security → Accessibility",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
const HEALTHY = {
|
|
23
|
+
checks: [{ name: "input source", status: "pass", message: "com.apple.keylayout.ABC" }],
|
|
24
|
+
};
|
|
25
|
+
let pythonPath;
|
|
26
|
+
let scriptPath;
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "aximo-preflight-"));
|
|
29
|
+
pythonPath = join(dir, "python3");
|
|
30
|
+
scriptPath = join(dir, "remote.py");
|
|
31
|
+
writeFileSync(pythonPath, "");
|
|
32
|
+
writeFileSync(scriptPath, "");
|
|
33
|
+
});
|
|
34
|
+
function spawnReturning(overrides) {
|
|
35
|
+
return vi.fn(() => ({
|
|
36
|
+
status: 0,
|
|
37
|
+
signal: null,
|
|
38
|
+
pid: 1,
|
|
39
|
+
output: [],
|
|
40
|
+
stdout: "",
|
|
41
|
+
stderr: "",
|
|
42
|
+
...overrides,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
describe("runLocalPreflight", () => {
|
|
46
|
+
it("returns the report the agent printed", () => {
|
|
47
|
+
const spawn = spawnReturning({ stdout: JSON.stringify(REPORT) });
|
|
48
|
+
expect(runLocalPreflight({ pythonPath, scriptPath, spawn })).toEqual({
|
|
49
|
+
kind: "ok",
|
|
50
|
+
report: REPORT,
|
|
51
|
+
});
|
|
52
|
+
expect(spawn).toHaveBeenCalledWith(pythonPath, [scriptPath, "--preflight"], expect.objectContaining({ encoding: "utf-8" }));
|
|
53
|
+
});
|
|
54
|
+
// A server still serving an agent script without --preflight exits on the
|
|
55
|
+
// missing GATEWAY_URL rather than printing a report.
|
|
56
|
+
it("reports not-installed when the agent script predates --preflight", () => {
|
|
57
|
+
const spawn = spawnReturning({ status: 1, stderr: "GATEWAY_URL is required" });
|
|
58
|
+
expect(runLocalPreflight({ pythonPath, scriptPath, spawn })).toEqual({
|
|
59
|
+
kind: "not-installed",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// A crash is not the same as "not set up yet", and doctor must not conflate them.
|
|
63
|
+
it("reports the failure when the agent runs but cannot answer", () => {
|
|
64
|
+
expect(runLocalPreflight({
|
|
65
|
+
pythonPath,
|
|
66
|
+
scriptPath,
|
|
67
|
+
spawn: spawnReturning({ status: 1, stderr: "ImportError: no module named mss" }),
|
|
68
|
+
})).toEqual({ kind: "error", detail: "ImportError: no module named mss" });
|
|
69
|
+
});
|
|
70
|
+
it("reports an error when stdout is not a report", () => {
|
|
71
|
+
for (const stdout of ["boom", "[]", JSON.stringify({ checks: [{ name: "x" }] })]) {
|
|
72
|
+
expect(runLocalPreflight({ pythonPath, scriptPath, spawn: spawnReturning({ stdout }) })).toEqual({ kind: "error", detail: expect.stringContaining("readable report") });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
it("reports not-installed without spawning when python is missing", () => {
|
|
76
|
+
const spawn = spawnReturning({ stdout: JSON.stringify(REPORT) });
|
|
77
|
+
expect(runLocalPreflight({ pythonPath: join(tmpdir(), "no-such-python"), scriptPath, spawn })).toEqual({ kind: "not-installed" });
|
|
78
|
+
expect(spawn).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("formatPreflightLines", () => {
|
|
82
|
+
it("marks each check and indents its hint", () => {
|
|
83
|
+
const lines = formatPreflightLines(REPORT);
|
|
84
|
+
expect(lines[0]).toBe("Desktop environment:");
|
|
85
|
+
expect(lines[1]).toContain("· capture target: capturing the primary monitor");
|
|
86
|
+
expect(lines[2]).toContain("✘ accessibility permission: not granted");
|
|
87
|
+
expect(lines[2]).toContain("→ System Settings");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("hasBlockingChecks", () => {
|
|
91
|
+
it("is true only when something warns or fails", () => {
|
|
92
|
+
expect(hasBlockingChecks(REPORT)).toBe(true);
|
|
93
|
+
expect(hasBlockingChecks(HEALTHY)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("confirmProceed", () => {
|
|
97
|
+
it("does not ask when every check is fine", async () => {
|
|
98
|
+
const ask = vi.fn();
|
|
99
|
+
expect(await confirmProceed(HEALTHY, { interactive: true, ask })).toBe(true);
|
|
100
|
+
expect(ask).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
it("proceeds on an empty answer", async () => {
|
|
103
|
+
expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "" })).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
it("stops on a declined prompt", async () => {
|
|
106
|
+
expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "n" })).toBe(false);
|
|
107
|
+
expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "No" })).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
// Headless/CI: nobody can answer, so a prompt would read as a hang.
|
|
110
|
+
it("never asks without a terminal", async () => {
|
|
111
|
+
const ask = vi.fn();
|
|
112
|
+
expect(await confirmProceed(REPORT, { interactive: false, ask })).toBe(true);
|
|
113
|
+
expect(ask).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -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;
|
|
@@ -35,11 +57,13 @@ export declare class AppiumController implements MobileController {
|
|
|
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>;
|
|
37
59
|
pinchZoom(x: number, y: number, scale: number, durationMs: number): Promise<void>;
|
|
60
|
+
private pinchZoomOnce;
|
|
38
61
|
private performPointer;
|
|
39
62
|
typeText(text: string): Promise<ActionResult>;
|
|
40
63
|
private activeElementOrNull;
|
|
41
64
|
clearText(): Promise<ActionResult>;
|
|
42
65
|
pressKey(key: string): Promise<ActionResult>;
|
|
66
|
+
private pressKeyOnce;
|
|
43
67
|
launchApp(app: string): Promise<ActionResult>;
|
|
44
68
|
findElement(text: string): Promise<ActionResult>;
|
|
45
69
|
private locate;
|
|
@@ -24,13 +24,22 @@ const W3C_KEYS = {
|
|
|
24
24
|
escape: "\uE00C",
|
|
25
25
|
space: " ",
|
|
26
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
|
+
}
|
|
27
34
|
export class AppiumController {
|
|
28
35
|
session;
|
|
29
36
|
platform;
|
|
30
37
|
target;
|
|
31
38
|
iosSigning;
|
|
32
39
|
wdaLocalPort;
|
|
40
|
+
mjpegServerPort;
|
|
33
41
|
resolvedWdaPort;
|
|
42
|
+
resolvedMjpegPort;
|
|
34
43
|
sessionReady = null;
|
|
35
44
|
scaling = null;
|
|
36
45
|
constructor(opts) {
|
|
@@ -38,10 +47,14 @@ export class AppiumController {
|
|
|
38
47
|
this.target = opts.target;
|
|
39
48
|
this.iosSigning = opts.iosSigning;
|
|
40
49
|
this.wdaLocalPort = opts.wdaLocalPort;
|
|
50
|
+
this.mjpegServerPort = opts.mjpegServerPort;
|
|
41
51
|
const transport = opts.transport ?? fetchTransport(opts.endpoint);
|
|
42
52
|
this.session = new WebDriverSession(transport);
|
|
43
53
|
}
|
|
44
54
|
capabilities() {
|
|
55
|
+
const mjpeg = this.resolvedMjpegPort
|
|
56
|
+
? { "appium:mjpegServerPort": this.resolvedMjpegPort }
|
|
57
|
+
: {};
|
|
45
58
|
if (this.platform === "android") {
|
|
46
59
|
return {
|
|
47
60
|
platformName: "Android",
|
|
@@ -50,6 +63,10 @@ export class AppiumController {
|
|
|
50
63
|
"appium:noReset": true,
|
|
51
64
|
"appium:autoLaunch": false,
|
|
52
65
|
"appium:newCommandTimeout": 0,
|
|
66
|
+
// ColorOS-family devices (OPPO/Realme/OnePlus) block the hidden-api-policy
|
|
67
|
+
// write; warn and continue instead of failing session creation (GEN-2741).
|
|
68
|
+
"appium:ignoreHiddenApiPolicyError": true,
|
|
69
|
+
...mjpeg,
|
|
53
70
|
};
|
|
54
71
|
}
|
|
55
72
|
return {
|
|
@@ -59,6 +76,7 @@ export class AppiumController {
|
|
|
59
76
|
"appium:newCommandTimeout": 0,
|
|
60
77
|
// Unique local->device WDA port so a second connected phone doesn't collide on 8100.
|
|
61
78
|
...(this.resolvedWdaPort ? { "appium:wdaLocalPort": this.resolvedWdaPort } : {}),
|
|
79
|
+
...mjpeg,
|
|
62
80
|
// WDA code-signing caps for physical iPhones (empty unless a team id is set).
|
|
63
81
|
...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
|
|
64
82
|
};
|
|
@@ -68,11 +86,14 @@ export class AppiumController {
|
|
|
68
86
|
this.sessionReady = (async () => {
|
|
69
87
|
this.resolvedWdaPort =
|
|
70
88
|
typeof this.wdaLocalPort === "function" ? await this.wdaLocalPort() : this.wdaLocalPort;
|
|
89
|
+
this.resolvedMjpegPort =
|
|
90
|
+
typeof this.mjpegServerPort === "function"
|
|
91
|
+
? await this.mjpegServerPort()
|
|
92
|
+
: this.mjpegServerPort;
|
|
71
93
|
await this.session.createSession(this.capabilities());
|
|
94
|
+
await this.applyMjpegSettings();
|
|
72
95
|
})().catch(async (err) => {
|
|
73
|
-
this.
|
|
74
|
-
// A retry must re-pick a free port — the one resolved here may be taken by then.
|
|
75
|
-
this.resolvedWdaPort = undefined;
|
|
96
|
+
this.resetSession();
|
|
76
97
|
// XCUITest reports session-start failures generically ("xcodebuild failed
|
|
77
98
|
// with code 65"); enrich with the real reason from the WDA .xcresult and
|
|
78
99
|
// an actionable hint so the user isn't left with an opaque error.
|
|
@@ -84,6 +105,61 @@ export class AppiumController {
|
|
|
84
105
|
}
|
|
85
106
|
return this.sessionReady;
|
|
86
107
|
}
|
|
108
|
+
// Conservative stream shape until runtime adaptation lands: iOS defaults to
|
|
109
|
+
// scalingFactor 100 (full-res frames) which can drive WDA CPU high enough for
|
|
110
|
+
// the OS to kill it, so both platforms are pinned to the same explicit values.
|
|
111
|
+
static MJPEG_SETTINGS = {
|
|
112
|
+
mjpegServerFramerate: 10,
|
|
113
|
+
mjpegScalingFactor: 50,
|
|
114
|
+
mjpegServerScreenshotQuality: 40,
|
|
115
|
+
};
|
|
116
|
+
async applyMjpegSettings() {
|
|
117
|
+
if (!this.resolvedMjpegPort)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
await this.session.updateSettings(AppiumController.MJPEG_SETTINGS);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`MJPEG capture settings were not applied (streaming continues with driver defaults): ${err instanceof Error ? err.message : String(err)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
resetSession() {
|
|
127
|
+
this.sessionReady = null;
|
|
128
|
+
this.scaling = null;
|
|
129
|
+
// A retry must re-pick free ports — the ones resolved here may be taken by then.
|
|
130
|
+
this.resolvedWdaPort = undefined;
|
|
131
|
+
this.resolvedMjpegPort = undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Run a command against a live session, rebuilding the session once if the
|
|
135
|
+
* cached one has died (WDA killed for CPU, device rebooted, adb dropped).
|
|
136
|
+
* Without this, one on-device crash permanently wedges every later command —
|
|
137
|
+
* including the screenshot fallback — until the CLI is restarted.
|
|
138
|
+
*/
|
|
139
|
+
async withSession(fn) {
|
|
140
|
+
const ready = this.ensureSession();
|
|
141
|
+
await ready;
|
|
142
|
+
try {
|
|
143
|
+
return await fn();
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (!isSessionDeadError(err))
|
|
147
|
+
throw err;
|
|
148
|
+
// Only reset if no concurrent command already rebuilt the session.
|
|
149
|
+
if (this.sessionReady === ready)
|
|
150
|
+
this.resetSession();
|
|
151
|
+
await this.ensureSession();
|
|
152
|
+
return await fn();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Bootstrap (or rebuild) the device session without running a command. */
|
|
156
|
+
async ensureReady() {
|
|
157
|
+
await this.ensureSession();
|
|
158
|
+
}
|
|
159
|
+
/** Local port of the driver-forwarded MJPEG server, once the session is up. */
|
|
160
|
+
getMjpegPort() {
|
|
161
|
+
return this.resolvedMjpegPort;
|
|
162
|
+
}
|
|
87
163
|
// Incoming pixel coords scale to logical points: iOS divides by DPR, Android ~= 1.
|
|
88
164
|
async scale() {
|
|
89
165
|
if (this.scaling)
|
|
@@ -109,57 +185,62 @@ export class AppiumController {
|
|
|
109
185
|
];
|
|
110
186
|
}
|
|
111
187
|
async screenshot() {
|
|
112
|
-
|
|
113
|
-
return this.session.screenshot();
|
|
188
|
+
return this.withSession(() => this.session.screenshot());
|
|
114
189
|
}
|
|
115
190
|
async tap(x, y) {
|
|
116
|
-
await this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
191
|
+
await this.withSession(async () => {
|
|
192
|
+
const [px, py] = await this.toPoint(x, y);
|
|
193
|
+
await this.performPointer([
|
|
194
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
195
|
+
{ type: "pointerDown", button: 0 },
|
|
196
|
+
{ type: "pause", duration: 50 },
|
|
197
|
+
{ type: "pointerUp", button: 0 },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
124
200
|
}
|
|
125
201
|
async doubleTap(x, y) {
|
|
126
|
-
await this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
202
|
+
await this.withSession(async () => {
|
|
203
|
+
const [px, py] = await this.toPoint(x, y);
|
|
204
|
+
await this.performPointer([
|
|
205
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
206
|
+
{ type: "pointerDown", button: 0 },
|
|
207
|
+
{ type: "pause", duration: 50 },
|
|
208
|
+
{ type: "pointerUp", button: 0 },
|
|
209
|
+
{ type: "pause", duration: 50 },
|
|
210
|
+
{ type: "pointerDown", button: 0 },
|
|
211
|
+
{ type: "pause", duration: 50 },
|
|
212
|
+
{ type: "pointerUp", button: 0 },
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
138
215
|
}
|
|
139
216
|
async longPress(x, y, durationMs) {
|
|
140
|
-
await this.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
217
|
+
await this.withSession(async () => {
|
|
218
|
+
const [px, py] = await this.toPoint(x, y);
|
|
219
|
+
await this.performPointer([
|
|
220
|
+
{ type: "pointerMove", duration: 0, x: px, y: py },
|
|
221
|
+
{ type: "pointerDown", button: 0 },
|
|
222
|
+
{ type: "pause", duration: Math.max(0, Math.round(durationMs)) },
|
|
223
|
+
{ type: "pointerUp", button: 0 },
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
148
226
|
}
|
|
149
227
|
async swipe(x1, y1, x2, y2, durationMs) {
|
|
150
|
-
await this.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
228
|
+
await this.withSession(async () => {
|
|
229
|
+
const [px1, py1] = await this.toPoint(x1, y1);
|
|
230
|
+
const [px2, py2] = await this.toPoint(x2, y2);
|
|
231
|
+
await this.performPointer([
|
|
232
|
+
{ type: "pointerMove", duration: 0, x: px1, y: py1 },
|
|
233
|
+
{ type: "pointerDown", button: 0 },
|
|
234
|
+
{ type: "pointerMove", duration: Math.max(0, Math.round(durationMs)), x: px2, y: py2 },
|
|
235
|
+
{ type: "pointerUp", button: 0 },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
159
238
|
}
|
|
160
239
|
// Two-finger vertical pinch: scale < 1 pinches in (zoom out), > 1 spreads (zoom in).
|
|
161
240
|
async pinchZoom(x, y, scale, durationMs) {
|
|
162
|
-
await this.
|
|
241
|
+
await this.withSession(() => this.pinchZoomOnce(x, y, scale, durationMs));
|
|
242
|
+
}
|
|
243
|
+
async pinchZoomOnce(x, y, scale, durationMs) {
|
|
163
244
|
const [cx, cy] = await this.toPoint(x, y);
|
|
164
245
|
const startOffset = 100;
|
|
165
246
|
const endOffset = startOffset * scale;
|
|
@@ -187,31 +268,32 @@ export class AppiumController {
|
|
|
187
268
|
await this.session.releaseActions();
|
|
188
269
|
}
|
|
189
270
|
async typeText(text) {
|
|
190
|
-
await this.ensureSession();
|
|
191
271
|
try {
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
272
|
+
return await this.withSession(async () => {
|
|
273
|
+
if (this.platform === "android") {
|
|
274
|
+
if (ANDROID_INPUT_TEXT_UNSAFE.test(text)) {
|
|
275
|
+
const active = await this.activeElementOrNull();
|
|
276
|
+
if (!active) {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
error: "Cannot type special or non-ASCII characters: no input field is focused. Tap the field first.",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
await this.session.elementSendKeys(active, text);
|
|
283
|
+
return { ok: true };
|
|
200
284
|
}
|
|
201
|
-
await this.session.
|
|
285
|
+
await this.session.execute("mobile: type", [{ text }]);
|
|
202
286
|
return { ok: true };
|
|
203
287
|
}
|
|
204
|
-
|
|
288
|
+
// XCUITest: per-char W3C key actions; no focused element required.
|
|
289
|
+
const actions = [...text].flatMap((char) => [
|
|
290
|
+
{ type: "keyDown", value: char },
|
|
291
|
+
{ type: "keyUp", value: char },
|
|
292
|
+
]);
|
|
293
|
+
await this.session.performActions([{ type: "key", id: "keyboard", actions }]);
|
|
294
|
+
await this.session.releaseActions();
|
|
205
295
|
return { ok: true };
|
|
206
|
-
}
|
|
207
|
-
// XCUITest: per-char W3C key actions; no focused element required.
|
|
208
|
-
const actions = [...text].flatMap((char) => [
|
|
209
|
-
{ type: "keyDown", value: char },
|
|
210
|
-
{ type: "keyUp", value: char },
|
|
211
|
-
]);
|
|
212
|
-
await this.session.performActions([{ type: "key", id: "keyboard", actions }]);
|
|
213
|
-
await this.session.releaseActions();
|
|
214
|
-
return { ok: true };
|
|
296
|
+
});
|
|
215
297
|
}
|
|
216
298
|
catch (error) {
|
|
217
299
|
return fail(error);
|
|
@@ -227,37 +309,52 @@ export class AppiumController {
|
|
|
227
309
|
}
|
|
228
310
|
}
|
|
229
311
|
async clearText() {
|
|
230
|
-
await this.ensureSession();
|
|
231
312
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
313
|
+
return await this.withSession(async () => {
|
|
314
|
+
try {
|
|
315
|
+
const active = await this.session.getActiveElement();
|
|
316
|
+
if (active) {
|
|
317
|
+
await this.session.elementClear(active);
|
|
318
|
+
return { ok: true };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
// Re-throw a dead session so withSession can rebuild it; "no focused
|
|
323
|
+
// element" style rejections fall through to the fallback path.
|
|
324
|
+
if (isSessionDeadError(error))
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
if (this.platform === "android") {
|
|
328
|
+
await this.session.execute("mobile: pressKey", [
|
|
329
|
+
{ keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
|
|
330
|
+
]);
|
|
331
|
+
await this.session.execute("mobile: pressKey", [
|
|
332
|
+
{ keycode: ANDROID_KEY_CODES["delete"] },
|
|
333
|
+
]);
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: "Could not clear the field: no focused input element was found. " +
|
|
339
|
+
"Tap the field to focus it before calling clear_text.",
|
|
340
|
+
};
|
|
341
|
+
});
|
|
237
342
|
}
|
|
238
|
-
catch {
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
await this.session.execute("mobile: pressKey", [
|
|
242
|
-
{ keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
|
|
243
|
-
]);
|
|
244
|
-
await this.session.execute("mobile: pressKey", [{ keycode: ANDROID_KEY_CODES["delete"] }]);
|
|
245
|
-
return { ok: true };
|
|
246
|
-
}
|
|
247
|
-
catch (error) {
|
|
248
|
-
return fail(error);
|
|
249
|
-
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return fail(error);
|
|
250
345
|
}
|
|
251
|
-
return {
|
|
252
|
-
ok: false,
|
|
253
|
-
error: "Could not clear the field: no focused input element was found. " +
|
|
254
|
-
"Tap the field to focus it before calling clear_text.",
|
|
255
|
-
};
|
|
256
346
|
}
|
|
257
347
|
async pressKey(key) {
|
|
258
|
-
await this.ensureSession();
|
|
259
348
|
const normalized = key.toLowerCase().trim();
|
|
260
349
|
try {
|
|
350
|
+
return await this.withSession(() => this.pressKeyOnce(key, normalized));
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
return fail(error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async pressKeyOnce(key, normalized) {
|
|
357
|
+
{
|
|
261
358
|
if (this.platform === "android") {
|
|
262
359
|
const code = ANDROID_KEY_CODES[normalized];
|
|
263
360
|
if (code === undefined)
|
|
@@ -293,18 +390,16 @@ export class AppiumController {
|
|
|
293
390
|
}
|
|
294
391
|
return { ok: false, error: `Unsupported key: ${key}` };
|
|
295
392
|
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
return fail(error);
|
|
298
|
-
}
|
|
299
393
|
}
|
|
300
394
|
async launchApp(app) {
|
|
301
395
|
if (!app)
|
|
302
396
|
return { ok: false, error: "launch_app requires an app id" };
|
|
303
|
-
await this.ensureSession();
|
|
304
397
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
398
|
+
return await this.withSession(async () => {
|
|
399
|
+
const param = this.platform === "android" ? { appId: app } : { bundleId: app };
|
|
400
|
+
await this.session.execute("mobile: activateApp", [param]);
|
|
401
|
+
return { ok: true };
|
|
402
|
+
});
|
|
308
403
|
}
|
|
309
404
|
catch (error) {
|
|
310
405
|
return fail(error);
|
|
@@ -313,13 +408,14 @@ export class AppiumController {
|
|
|
313
408
|
async findElement(text) {
|
|
314
409
|
if (!text)
|
|
315
410
|
return { ok: false, error: "find_element requires text" };
|
|
316
|
-
await this.ensureSession();
|
|
317
411
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
412
|
+
return await this.withSession(async () => {
|
|
413
|
+
const ref = await this.locate(text);
|
|
414
|
+
if (!ref)
|
|
415
|
+
return { ok: false, error: `Element with text "${text}" not found` };
|
|
416
|
+
await this.session.elementClick(ref);
|
|
417
|
+
return { ok: true };
|
|
418
|
+
});
|
|
323
419
|
}
|
|
324
420
|
catch (error) {
|
|
325
421
|
return fail(error);
|
|
@@ -339,53 +435,51 @@ export class AppiumController {
|
|
|
339
435
|
return null;
|
|
340
436
|
}
|
|
341
437
|
async hideKeyboard() {
|
|
342
|
-
await this.ensureSession();
|
|
343
438
|
try {
|
|
344
|
-
await this.session.execute("mobile: hideKeyboard", []);
|
|
345
|
-
return { ok: true };
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
return { ok: true };
|
|
439
|
+
await this.withSession(() => this.session.execute("mobile: hideKeyboard", []));
|
|
349
440
|
}
|
|
441
|
+
catch { }
|
|
442
|
+
return { ok: true };
|
|
350
443
|
}
|
|
351
444
|
async rotateDevice(orientation) {
|
|
352
|
-
await this.ensureSession();
|
|
353
445
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
446
|
+
return await this.withSession(async () => {
|
|
447
|
+
const wantLandscape = orientation === "landscape";
|
|
448
|
+
await this.session.setOrientation(wantLandscape ? "LANDSCAPE" : "PORTRAIT");
|
|
449
|
+
// Rotation changes the window rect, so the cached pixel->point scale is stale.
|
|
450
|
+
this.scaling = null;
|
|
451
|
+
// Poll up to 2s until the OS reports the new dimensions; rotation speed varies.
|
|
452
|
+
const deadline = Date.now() + 2000;
|
|
453
|
+
while (Date.now() < deadline) {
|
|
454
|
+
try {
|
|
455
|
+
const rect = await this.session.windowRect();
|
|
456
|
+
if (rect.width > rect.height === wantLandscape)
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
catch { }
|
|
460
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
365
461
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
return { ok: true };
|
|
462
|
+
return { ok: true };
|
|
463
|
+
});
|
|
370
464
|
}
|
|
371
465
|
catch (error) {
|
|
372
466
|
return fail(error);
|
|
373
467
|
}
|
|
374
468
|
}
|
|
375
469
|
async setLocation(latitude, longitude, altitude) {
|
|
376
|
-
await this.ensureSession();
|
|
377
470
|
try {
|
|
378
|
-
await this.
|
|
379
|
-
|
|
471
|
+
return await this.withSession(async () => {
|
|
472
|
+
await this.session.setGeoLocation({ latitude, longitude, altitude });
|
|
473
|
+
return { ok: true };
|
|
474
|
+
});
|
|
380
475
|
}
|
|
381
476
|
catch (error) {
|
|
382
477
|
return fail(error);
|
|
383
478
|
}
|
|
384
479
|
}
|
|
385
480
|
async getDeviceTime() {
|
|
386
|
-
await this.ensureSession();
|
|
387
481
|
try {
|
|
388
|
-
const raw = await this.session.execute("mobile: getDeviceTime", []);
|
|
482
|
+
const raw = await this.withSession(() => this.session.execute("mobile: getDeviceTime", []));
|
|
389
483
|
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
390
484
|
return null;
|
|
391
485
|
const parsed = new Date(raw);
|