@autifyhq/aximo-cli 0.10.1 → 0.11.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 +40 -3
- package/dist/cli.js +14 -4
- package/dist/commands/mcp/server.test.js +4 -1
- package/dist/commands/mcp/tools.js +43 -0
- package/dist/commands/mcp/tools.test.js +104 -0
- package/dist/commands/mobile/connect-core.js +1 -1
- package/dist/commands/mobile/doctor.d.ts +20 -3
- package/dist/commands/mobile/doctor.js +180 -13
- package/dist/commands/mobile/doctor.test.js +79 -3
- package/dist/desktop-server/agent-sync.d.ts +21 -1
- package/dist/desktop-server/agent-sync.js +6 -2
- package/dist/desktop-server/bundle-sync.d.ts +25 -0
- package/dist/desktop-server/bundle-sync.js +128 -0
- package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
- package/dist/desktop-server/bundle-sync.test.js +189 -0
- package/dist/desktop-server/cli-version.d.ts +21 -0
- package/dist/desktop-server/cli-version.js +41 -0
- package/dist/desktop-server/cli-version.test.d.ts +1 -0
- package/dist/desktop-server/cli-version.test.js +58 -0
- package/dist/desktop-server/offline.d.ts +6 -0
- package/dist/desktop-server/offline.js +44 -0
- package/dist/desktop-server/offline.test.d.ts +1 -0
- package/dist/desktop-server/offline.test.js +74 -0
- package/dist/desktop-server/setup-deps.test.d.ts +1 -0
- package/dist/desktop-server/setup-deps.test.js +112 -0
- package/dist/desktop-server/setup-python.test.d.ts +1 -0
- package/dist/desktop-server/setup-python.test.js +272 -0
- package/dist/desktop-server/setup.d.ts +26 -1
- package/dist/desktop-server/setup.js +136 -29
- package/dist/mobile-control/android-recovery.d.ts +5 -0
- package/dist/mobile-control/android-recovery.js +19 -0
- package/dist/mobile-control/android-recovery.test.d.ts +1 -0
- package/dist/mobile-control/android-recovery.test.js +33 -0
- package/dist/mobile-control/appium-controller.d.ts +4 -0
- package/dist/mobile-control/appium-controller.js +15 -1
- package/dist/mobile-control/appium-controller.test.js +78 -0
- package/dist/mobile-control/appium-import.js +19 -3
- package/dist/mobile-control/ios-tunnel.d.ts +28 -8
- package/dist/mobile-control/ios-tunnel.js +63 -27
- package/dist/mobile-control/ios-tunnel.test.js +153 -14
- package/dist/mobile-control/mobile-errors.js +6 -0
- package/dist/mobile-control/mobile-errors.test.js +3 -0
- package/dist/types.d.ts +49 -0
- package/package.json +4 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
-
import { arch as osArch, platform } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
import { Readable } from "node:stream";
|
|
7
6
|
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { DEFAULT_BASE_URL, getBaseUrl } from "../client.js";
|
|
8
8
|
import { getAgentCaBundlePath, getAximoDir, getCacheDir, getDepsMarkerPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, getRemoteLogPath, } from "../commands/device/paths.js";
|
|
9
9
|
import { ensureAgent } from "./agent-sync.js";
|
|
10
|
+
import { warnIfCliOutdated } from "./cli-version.js";
|
|
11
|
+
import { findOfflinePythonArchive, findOfflineWheelsDir } from "./offline.js";
|
|
12
|
+
import { downloadServerPython, ensureServerWheels, platformKey, resolveServerPython, } from "./bundle-sync.js";
|
|
10
13
|
export const PYTHON_RELEASE = "20260414";
|
|
11
14
|
export const PYTHON_VERSION = "3.12.13";
|
|
12
15
|
const BUILDS = {
|
|
@@ -36,16 +39,19 @@ const BUILDS = {
|
|
|
36
39
|
},
|
|
37
40
|
};
|
|
38
41
|
function selectBuild() {
|
|
39
|
-
const key =
|
|
42
|
+
const key = platformKey();
|
|
40
43
|
const build = BUILDS[key];
|
|
41
44
|
if (!build) {
|
|
42
45
|
throw new Error(`Unsupported platform/arch: ${key}`);
|
|
43
46
|
}
|
|
44
47
|
return build;
|
|
45
48
|
}
|
|
49
|
+
/** Filename python-build-standalone publishes for this build, also used on disk. */
|
|
50
|
+
export function pythonArchiveName(build = selectBuild()) {
|
|
51
|
+
return `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}-${build.target}-install_only.tar.gz`;
|
|
52
|
+
}
|
|
46
53
|
function buildDownloadUrl(build) {
|
|
47
|
-
|
|
48
|
-
return `https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RELEASE}/${filename}`;
|
|
54
|
+
return `https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RELEASE}/${pythonArchiveName(build)}`;
|
|
49
55
|
}
|
|
50
56
|
export function installedPythonTag() {
|
|
51
57
|
return `${PYTHON_RELEASE}-${PYTHON_VERSION}`;
|
|
@@ -92,23 +98,93 @@ function extractTarball(tarball, destDir) {
|
|
|
92
98
|
throw new Error(`tar extraction failed (exit ${result.status})`);
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a verified Python archive from the nearest available source.
|
|
103
|
+
*
|
|
104
|
+
* Order is staged on disk, then the Aximo server, then GitHub. Every branch
|
|
105
|
+
* checks the same compiled-in SHA256, so nothing gets a weaker guarantee for
|
|
106
|
+
* being local — and because that constant ships with the CLI, a mismatched
|
|
107
|
+
* archive from any source is caught.
|
|
108
|
+
*/
|
|
109
|
+
async function acquirePythonTarball(build, manifest, deps) {
|
|
110
|
+
const archiveName = pythonArchiveName(build);
|
|
111
|
+
const staged = findOfflinePythonArchive(archiveName);
|
|
112
|
+
if (staged) {
|
|
113
|
+
console.log(`Using staged embedded Python ${PYTHON_VERSION} (${build.target})...`);
|
|
114
|
+
// Never delete a staged archive — it belongs to the user, not the cache.
|
|
115
|
+
verifyArchive(staged, build.sha256, { removeOnMismatch: false });
|
|
116
|
+
return { path: staged, ephemeral: false };
|
|
117
|
+
}
|
|
118
|
+
const served = manifest && resolveServerPython(manifest, archiveName);
|
|
119
|
+
if (served) {
|
|
120
|
+
const path = await downloadServerPython(served, deps ?? {});
|
|
121
|
+
verifyArchive(path, build.sha256, { removeOnMismatch: true });
|
|
122
|
+
// Kept in the cache: re-running setup on the same machine then costs nothing,
|
|
123
|
+
// which matters most on the slow internal links these servers often sit on.
|
|
124
|
+
return { path, ephemeral: false };
|
|
125
|
+
}
|
|
126
|
+
const path = join(getCacheDir(), `python-${installedPythonTag()}.tar.gz`);
|
|
127
|
+
console.log(`Fetching embedded Python ${PYTHON_VERSION} (${build.target})...`);
|
|
128
|
+
try {
|
|
129
|
+
await downloadTarball(buildDownloadUrl(build), path, build.sha256);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw new Error(`${err instanceof Error ? err.message : String(err)}${fallbackHint(deps)}`);
|
|
133
|
+
}
|
|
134
|
+
return { path, ephemeral: true };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Extra context for a failed GitHub download, when it would actually help.
|
|
138
|
+
*
|
|
139
|
+
* Reaching GitHub means the server offered nothing for this platform. Air-gapped
|
|
140
|
+
* that can never succeed, and the bare network error gives no hint that a missing
|
|
141
|
+
* server-side bundle is the cause. But this same path is the normal one for cloud,
|
|
142
|
+
* where a transient GitHub failure has nothing to do with on-prem bundles — so
|
|
143
|
+
* only mention them when the CLI is pointed somewhere other than cloud.
|
|
144
|
+
*/
|
|
145
|
+
function fallbackHint(deps) {
|
|
146
|
+
const baseUrl = deps?.baseUrl ?? getBaseUrl();
|
|
147
|
+
if (baseUrl === DEFAULT_BASE_URL)
|
|
148
|
+
return "";
|
|
149
|
+
return (`\n The Aximo server at ${baseUrl} offered no embedded Python for ${platformKey()}, ` +
|
|
150
|
+
"so this fell back to downloading from GitHub.\n" +
|
|
151
|
+
" If this machine has no internet access, that server needs the CLI bundle from its " +
|
|
152
|
+
"on-prem release, or stage the archive locally and set AXIMO_OFFLINE_DIR.");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Check an archive against the CLI's own compiled-in checksum.
|
|
156
|
+
*
|
|
157
|
+
* `removeOnMismatch` matters for recovery, not tidiness: a cached download still
|
|
158
|
+
* matches the manifest that produced it, so leaving a rejected file in place makes
|
|
159
|
+
* every later run fail identically until someone clears the cache by hand. A
|
|
160
|
+
* staged archive belongs to the user, so that one is never removed.
|
|
161
|
+
*/
|
|
162
|
+
function verifyArchive(path, expectedSha256, { removeOnMismatch }) {
|
|
163
|
+
const actual = hashFile(path);
|
|
164
|
+
if (actual === expectedSha256)
|
|
165
|
+
return;
|
|
166
|
+
if (removeOnMismatch)
|
|
167
|
+
rmSync(path, { force: true });
|
|
168
|
+
throw new Error(`SHA256 mismatch for ${path}\n expected ${expectedSha256}\n actual ${actual}\n` +
|
|
169
|
+
" This CLI and the interpreter it was given do not match. Install the aximo-cli " +
|
|
170
|
+
"that ships with your current Aximo release.");
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Extract an already-verified Python archive into place and stamp the markers.
|
|
174
|
+
*
|
|
175
|
+
* Callers are responsible for integrity: by the time an archive reaches here it
|
|
176
|
+
* must have been checked against the expected SHA256.
|
|
177
|
+
*/
|
|
178
|
+
export function installPythonArchive(tarball, { keepArchive }) {
|
|
96
179
|
const pythonDir = getPythonDir();
|
|
97
180
|
const cacheDir = getCacheDir();
|
|
98
|
-
const pythonMarker = getPythonMarkerPath();
|
|
99
|
-
const depsMarker = getDepsMarkerPath();
|
|
100
181
|
if (existsSync(pythonDir)) {
|
|
101
182
|
rmSync(pythonDir, { recursive: true, force: true });
|
|
102
183
|
}
|
|
103
|
-
const build = selectBuild();
|
|
104
|
-
const url = buildDownloadUrl(build);
|
|
105
184
|
mkdirSync(cacheDir, { recursive: true });
|
|
106
|
-
const tarball = join(cacheDir, `python-${installedPythonTag()}.tar.gz`);
|
|
107
185
|
const staging = join(cacheDir, "python-staging");
|
|
108
186
|
if (existsSync(staging))
|
|
109
187
|
rmSync(staging, { recursive: true, force: true });
|
|
110
|
-
console.log(`Fetching embedded Python ${PYTHON_VERSION} (${build.target})...`);
|
|
111
|
-
await downloadTarball(url, tarball, build.sha256);
|
|
112
188
|
console.log("Extracting...");
|
|
113
189
|
extractTarball(tarball, staging);
|
|
114
190
|
const extracted = join(staging, "python");
|
|
@@ -118,25 +194,47 @@ async function installEmbeddedPython() {
|
|
|
118
194
|
mkdirSync(getAximoDir(), { recursive: true });
|
|
119
195
|
renameSync(extracted, pythonDir);
|
|
120
196
|
rmSync(staging, { recursive: true, force: true });
|
|
121
|
-
|
|
122
|
-
|
|
197
|
+
// A staged or server-cached archive is kept: it belongs to the user, or paying
|
|
198
|
+
// the download again on the next run costs more than the disk.
|
|
199
|
+
if (!keepArchive)
|
|
200
|
+
rmSync(tarball, { force: true });
|
|
201
|
+
writeFileSync(getPythonMarkerPath(), installedPythonTag(), { mode: 0o644 });
|
|
202
|
+
const depsMarker = getDepsMarkerPath();
|
|
123
203
|
if (existsSync(depsMarker))
|
|
124
204
|
rmSync(depsMarker, { force: true });
|
|
125
205
|
}
|
|
126
|
-
async function
|
|
206
|
+
async function installEmbeddedPython(manifest, deps) {
|
|
207
|
+
mkdirSync(getCacheDir(), { recursive: true });
|
|
208
|
+
const source = await acquirePythonTarball(selectBuild(), manifest, deps);
|
|
209
|
+
installPythonArchive(source.path, { keepArchive: !source.ephemeral });
|
|
210
|
+
}
|
|
211
|
+
async function ensurePython(manifest, deps) {
|
|
127
212
|
const binary = getEmbeddedPythonBinary();
|
|
128
213
|
const markerPath = getPythonMarkerPath();
|
|
129
214
|
const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf-8").trim() : "";
|
|
130
215
|
if (marker === installedPythonTag() && existsSync(binary)) {
|
|
131
216
|
return binary;
|
|
132
217
|
}
|
|
133
|
-
await installEmbeddedPython();
|
|
218
|
+
await installEmbeddedPython(manifest, deps);
|
|
134
219
|
return binary;
|
|
135
220
|
}
|
|
136
221
|
function hashFile(path) {
|
|
137
222
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
138
223
|
}
|
|
139
|
-
|
|
224
|
+
/** pip argv for the agent's requirements, resolving from wheelsDir when given. */
|
|
225
|
+
export function pipInstallArgs(requirementsPath, wheelsDir) {
|
|
226
|
+
const args = ["-m", "pip", "install", "--disable-pip-version-check", "--no-warn-script-location"];
|
|
227
|
+
// --no-index is what makes this airtight: pip must not reach PyPI even for a
|
|
228
|
+
// single unsatisfied transitive dependency.
|
|
229
|
+
if (wheelsDir)
|
|
230
|
+
args.push("--no-index", "--find-links", wheelsDir);
|
|
231
|
+
args.push("-r", requirementsPath);
|
|
232
|
+
return args;
|
|
233
|
+
}
|
|
234
|
+
function runPip(file, args) {
|
|
235
|
+
return spawnSync(file, args, { stdio: "inherit" });
|
|
236
|
+
}
|
|
237
|
+
export function ensureDependencies(python, requirementsPath, options = {}) {
|
|
140
238
|
if (!existsSync(requirementsPath)) {
|
|
141
239
|
throw new Error(`requirements.txt not found at ${requirementsPath}`);
|
|
142
240
|
}
|
|
@@ -145,28 +243,37 @@ function ensureDependencies(python, requirementsPath) {
|
|
|
145
243
|
const marker = existsSync(depsMarker) ? readFileSync(depsMarker, "utf-8").trim() : "";
|
|
146
244
|
if (marker === requirementsHash)
|
|
147
245
|
return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
|
|
153
|
-
"--disable-pip-version-check",
|
|
154
|
-
"--no-warn-script-location",
|
|
155
|
-
"-r",
|
|
156
|
-
requirementsPath,
|
|
157
|
-
], { stdio: "inherit" });
|
|
246
|
+
const { wheelsDir, exec = runPip } = options;
|
|
247
|
+
console.log(wheelsDir
|
|
248
|
+
? `Installing Python dependencies from ${wheelsDir}...`
|
|
249
|
+
: "Installing Python dependencies...");
|
|
250
|
+
const result = exec(python, pipInstallArgs(requirementsPath, wheelsDir));
|
|
158
251
|
if (result.status !== 0) {
|
|
252
|
+
if (wheelsDir) {
|
|
253
|
+
// Offline, so the usual causes (network, index) don't apply — an
|
|
254
|
+
// incomplete wheel set almost always means the bundle predates the
|
|
255
|
+
// requirements the server is now serving.
|
|
256
|
+
throw new Error(`pip install failed (exit ${result.status}). The wheels in ${wheelsDir} do not satisfy ` +
|
|
257
|
+
`${requirementsPath}. This usually means the CLI bundle is older than the requirements ` +
|
|
258
|
+
"your Aximo server is serving — install the CLI bundle from your current on-prem release.");
|
|
259
|
+
}
|
|
159
260
|
throw new Error(`pip install failed (exit ${result.status})`);
|
|
160
261
|
}
|
|
161
262
|
writeFileSync(depsMarker, requirementsHash, { mode: 0o644 });
|
|
162
263
|
}
|
|
163
264
|
export async function ensurePythonReady(deps) {
|
|
164
|
-
|
|
265
|
+
// The agent manifest comes first: it is what tells us whether the server can
|
|
266
|
+
// supply the interpreter and wheels, which decides where Python comes from.
|
|
165
267
|
const agent = await ensureAgent(deps);
|
|
166
268
|
if (agent.changed) {
|
|
167
269
|
console.log(`Agent script updated to version ${agent.version}`);
|
|
168
270
|
}
|
|
169
|
-
|
|
271
|
+
warnIfCliOutdated(agent.manifest);
|
|
272
|
+
const pythonPath = await ensurePython(agent.manifest, deps);
|
|
273
|
+
// Staged files win over the server so a machine that cannot reach it at
|
|
274
|
+
// all — or is being prepared ahead of time — still works.
|
|
275
|
+
const wheelsDir = findOfflineWheelsDir() ?? (await ensureServerWheels(agent.manifest, deps));
|
|
276
|
+
ensureDependencies(pythonPath, agent.requirementsPath, { wheelsDir });
|
|
170
277
|
return {
|
|
171
278
|
pythonPath,
|
|
172
279
|
scriptPath: agent.scriptPath,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
export declare function isWedgedInstrumentationError(err: unknown): boolean;
|
|
3
|
+
export declare const APPIUM_HELPER_PACKAGES: readonly ["io.appium.uiautomator2.server.test", "io.appium.uiautomator2.server", "io.appium.settings"];
|
|
4
|
+
/** Remove stale Appium helper APKs (best-effort) so the driver reinstalls fresh ones. */
|
|
5
|
+
export declare function uninstallAppiumHelpers(serial: string, exec?: ExecFn): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execBinary } from "./exec.js";
|
|
2
|
+
// Helper APKs left by an older driver version can wedge instrumentation: the
|
|
3
|
+
// packages are installed but Android refuses to register the test runner, so
|
|
4
|
+
// every session fails with one of these until the stale APKs are removed.
|
|
5
|
+
const WEDGED_INSTRUMENTATION_PATTERN = /instrumentation process cannot be initialized|Unable to find instrumentation info|INSTRUMENTATION_FAILED/i;
|
|
6
|
+
export function isWedgedInstrumentationError(err) {
|
|
7
|
+
return err instanceof Error && WEDGED_INSTRUMENTATION_PATTERN.test(err.message);
|
|
8
|
+
}
|
|
9
|
+
export const APPIUM_HELPER_PACKAGES = [
|
|
10
|
+
"io.appium.uiautomator2.server.test",
|
|
11
|
+
"io.appium.uiautomator2.server",
|
|
12
|
+
"io.appium.settings",
|
|
13
|
+
];
|
|
14
|
+
/** Remove stale Appium helper APKs (best-effort) so the driver reinstalls fresh ones. */
|
|
15
|
+
export async function uninstallAppiumHelpers(serial, exec = execBinary) {
|
|
16
|
+
for (const pkg of APPIUM_HELPER_PACKAGES) {
|
|
17
|
+
await exec("adb", ["-s", serial, "uninstall", pkg]).catch(() => { });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { APPIUM_HELPER_PACKAGES, isWedgedInstrumentationError, uninstallAppiumHelpers, } from "./android-recovery.js";
|
|
3
|
+
describe("isWedgedInstrumentationError", () => {
|
|
4
|
+
it("matches the driver's wedged-instrumentation signatures", () => {
|
|
5
|
+
expect(isWedgedInstrumentationError(new Error("WebDriver POST /session failed: unknown error: The instrumentation process cannot be initialized."))).toBe(true);
|
|
6
|
+
expect(isWedgedInstrumentationError(new Error("Unable to find instrumentation info for: ComponentInfo{io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner}"))).toBe(true);
|
|
7
|
+
expect(isWedgedInstrumentationError(new Error("INSTRUMENTATION_FAILED: io.appium.uiautomator2.server.test/..."))).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it("does not match unrelated session errors or non-errors", () => {
|
|
10
|
+
expect(isWedgedInstrumentationError(new Error("Could not find a connected Android device"))).toBe(false);
|
|
11
|
+
expect(isWedgedInstrumentationError("instrumentation process cannot be initialized")).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe("uninstallAppiumHelpers", () => {
|
|
15
|
+
it("uninstalls all helper packages for the given serial", async () => {
|
|
16
|
+
const calls = [];
|
|
17
|
+
const exec = async (_file, args) => {
|
|
18
|
+
calls.push(args);
|
|
19
|
+
return { stdout: Buffer.from("Success"), stderr: "" };
|
|
20
|
+
};
|
|
21
|
+
await uninstallAppiumHelpers("serial-1", exec);
|
|
22
|
+
expect(calls).toEqual(APPIUM_HELPER_PACKAGES.map((pkg) => ["-s", "serial-1", "uninstall", pkg]));
|
|
23
|
+
});
|
|
24
|
+
it("keeps going when an uninstall fails (package not installed)", async () => {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const exec = async (_file, args) => {
|
|
27
|
+
calls.push(args);
|
|
28
|
+
throw new Error("Failure [DELETE_FAILED_INTERNAL_ERROR]");
|
|
29
|
+
};
|
|
30
|
+
await uninstallAppiumHelpers("serial-1", exec);
|
|
31
|
+
expect(calls).toHaveLength(APPIUM_HELPER_PACKAGES.length);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionResult, MobileController } from "./controller.js";
|
|
2
|
+
import type { ExecFn } from "./exec.js";
|
|
2
3
|
import { type IosSigningConfig } from "./ios-signing.js";
|
|
3
4
|
import { type WebDriverTransport } from "./webdriver.js";
|
|
4
5
|
export interface AppiumControllerOptions {
|
|
@@ -19,6 +20,8 @@ export interface AppiumControllerOptions {
|
|
|
19
20
|
*/
|
|
20
21
|
mjpegServerPort?: number | (() => Promise<number>);
|
|
21
22
|
transport?: WebDriverTransport;
|
|
23
|
+
/** adb runner override for tests (used by the wedged-instrumentation repair). */
|
|
24
|
+
exec?: ExecFn;
|
|
22
25
|
}
|
|
23
26
|
export declare class AppiumController implements MobileController {
|
|
24
27
|
private readonly session;
|
|
@@ -27,6 +30,7 @@ export declare class AppiumController implements MobileController {
|
|
|
27
30
|
private readonly iosSigning;
|
|
28
31
|
private readonly wdaLocalPort;
|
|
29
32
|
private readonly mjpegServerPort;
|
|
33
|
+
private readonly exec;
|
|
30
34
|
private resolvedWdaPort;
|
|
31
35
|
private resolvedMjpegPort;
|
|
32
36
|
private sessionReady;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isWedgedInstrumentationError, uninstallAppiumHelpers } from "./android-recovery.js";
|
|
1
2
|
import { enrichIosSessionError } from "./ios-session-error.js";
|
|
2
3
|
import { iosSigningCapabilities } from "./ios-signing.js";
|
|
3
4
|
import { fetchTransport, WebDriverSession } from "./webdriver.js";
|
|
@@ -38,6 +39,7 @@ export class AppiumController {
|
|
|
38
39
|
iosSigning;
|
|
39
40
|
wdaLocalPort;
|
|
40
41
|
mjpegServerPort;
|
|
42
|
+
exec;
|
|
41
43
|
resolvedWdaPort;
|
|
42
44
|
resolvedMjpegPort;
|
|
43
45
|
sessionReady = null;
|
|
@@ -48,6 +50,7 @@ export class AppiumController {
|
|
|
48
50
|
this.iosSigning = opts.iosSigning;
|
|
49
51
|
this.wdaLocalPort = opts.wdaLocalPort;
|
|
50
52
|
this.mjpegServerPort = opts.mjpegServerPort;
|
|
53
|
+
this.exec = opts.exec;
|
|
51
54
|
const transport = opts.transport ?? fetchTransport(opts.endpoint);
|
|
52
55
|
this.session = new WebDriverSession(transport);
|
|
53
56
|
}
|
|
@@ -90,7 +93,18 @@ export class AppiumController {
|
|
|
90
93
|
typeof this.mjpegServerPort === "function"
|
|
91
94
|
? await this.mjpegServerPort()
|
|
92
95
|
: this.mjpegServerPort;
|
|
93
|
-
|
|
96
|
+
try {
|
|
97
|
+
await this.session.createSession(this.capabilities());
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// Stale helper APKs from an older driver wedge instrumentation until
|
|
101
|
+
// they are removed; wipe them and retry once so the driver reinstalls.
|
|
102
|
+
if (this.platform !== "android" || !isWedgedInstrumentationError(err))
|
|
103
|
+
throw err;
|
|
104
|
+
console.error("Stale Appium helper apps detected on the phone — removing them and retrying once.");
|
|
105
|
+
await uninstallAppiumHelpers(this.target, this.exec);
|
|
106
|
+
await this.session.createSession(this.capabilities());
|
|
107
|
+
}
|
|
94
108
|
await this.applyMjpegSettings();
|
|
95
109
|
})().catch(async (err) => {
|
|
96
110
|
this.resetSession();
|
|
@@ -95,6 +95,84 @@ function lastPointerActions(calls) {
|
|
|
95
95
|
const groups = body["actions"];
|
|
96
96
|
return groups[0]?.actions ?? [];
|
|
97
97
|
}
|
|
98
|
+
describe("AppiumController wedged-instrumentation recovery", () => {
|
|
99
|
+
function wedgedTransport(failures) {
|
|
100
|
+
const calls = [];
|
|
101
|
+
const transport = {
|
|
102
|
+
async request(method, path, body) {
|
|
103
|
+
calls.push({ method, path, body });
|
|
104
|
+
if (method === "POST" && path === "/session") {
|
|
105
|
+
const failure = failures.shift();
|
|
106
|
+
if (failure)
|
|
107
|
+
throw new Error(failure);
|
|
108
|
+
return { sessionId: "sess-1" };
|
|
109
|
+
}
|
|
110
|
+
if (path.endsWith("/screenshot"))
|
|
111
|
+
return pngWithWidth(4);
|
|
112
|
+
return {};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return { transport, calls };
|
|
116
|
+
}
|
|
117
|
+
const WEDGED = "WebDriver POST /session failed: The instrumentation process cannot be initialized.";
|
|
118
|
+
it("uninstalls the helper APKs and retries once when instrumentation is wedged", async () => {
|
|
119
|
+
const adbCalls = [];
|
|
120
|
+
const exec = async (_file, args) => {
|
|
121
|
+
adbCalls.push(args);
|
|
122
|
+
return { stdout: Buffer.from(""), stderr: "" };
|
|
123
|
+
};
|
|
124
|
+
const { transport, calls } = wedgedTransport([WEDGED]);
|
|
125
|
+
const controller = new AppiumController({
|
|
126
|
+
endpoint: "http://x",
|
|
127
|
+
platform: "android",
|
|
128
|
+
target: "udid-1",
|
|
129
|
+
transport,
|
|
130
|
+
exec,
|
|
131
|
+
});
|
|
132
|
+
await controller.screenshot();
|
|
133
|
+
expect(calls.filter((c) => c.path === "/session" && c.method === "POST")).toHaveLength(2);
|
|
134
|
+
expect(adbCalls.map((a) => a.at(-1))).toEqual([
|
|
135
|
+
"io.appium.uiautomator2.server.test",
|
|
136
|
+
"io.appium.uiautomator2.server",
|
|
137
|
+
"io.appium.settings",
|
|
138
|
+
]);
|
|
139
|
+
expect(adbCalls.every((a) => a[0] === "-s" && a[1] === "udid-1")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it("does not attempt recovery for unrelated session errors", async () => {
|
|
142
|
+
const adbCalls = [];
|
|
143
|
+
const exec = async (_file, args) => {
|
|
144
|
+
adbCalls.push(args);
|
|
145
|
+
return { stdout: Buffer.from(""), stderr: "" };
|
|
146
|
+
};
|
|
147
|
+
const { transport } = wedgedTransport(["Could not find a connected Android device"]);
|
|
148
|
+
const controller = new AppiumController({
|
|
149
|
+
endpoint: "http://x",
|
|
150
|
+
platform: "android",
|
|
151
|
+
target: "udid-1",
|
|
152
|
+
transport,
|
|
153
|
+
exec,
|
|
154
|
+
});
|
|
155
|
+
await expect(controller.screenshot()).rejects.toThrow("connected Android device");
|
|
156
|
+
expect(adbCalls).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
it("does not attempt adb recovery on iOS", async () => {
|
|
159
|
+
const adbCalls = [];
|
|
160
|
+
const exec = async (_file, args) => {
|
|
161
|
+
adbCalls.push(args);
|
|
162
|
+
return { stdout: Buffer.from(""), stderr: "" };
|
|
163
|
+
};
|
|
164
|
+
const { transport } = wedgedTransport([WEDGED]);
|
|
165
|
+
const controller = new AppiumController({
|
|
166
|
+
endpoint: "http://x",
|
|
167
|
+
platform: "ios",
|
|
168
|
+
target: "udid-1",
|
|
169
|
+
transport,
|
|
170
|
+
exec,
|
|
171
|
+
});
|
|
172
|
+
await expect(controller.screenshot()).rejects.toThrow();
|
|
173
|
+
expect(adbCalls).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
98
176
|
describe("AppiumController session capabilities", () => {
|
|
99
177
|
it("sends Android UiAutomator2 caps", async () => {
|
|
100
178
|
const { controller, calls } = makeController("android");
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
const APPIUM = "appium";
|
|
2
2
|
export async function importAppium() {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
try {
|
|
4
|
+
// Appium 3 is CJS, so its `main` is under `.default` when imported from ESM.
|
|
5
|
+
const mod = (await import(APPIUM));
|
|
6
|
+
return mod.default ?? mod;
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
// appium is left out of the single-file build — it pulls in native code the
|
|
10
|
+
// executable cannot carry — but `aximo mobile` still appears in --help.
|
|
11
|
+
//
|
|
12
|
+
// Both codes are needed: a CJS require reports MODULE_NOT_FOUND, while the
|
|
13
|
+
// dynamic ESM import used here reports ERR_MODULE_NOT_FOUND. The raw message
|
|
14
|
+
// also names the bundle's build-time path, which means nothing to a customer.
|
|
15
|
+
const code = err?.code;
|
|
16
|
+
if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") {
|
|
17
|
+
throw new Error("Mobile commands are unavailable in the single-file aximo build. " +
|
|
18
|
+
"Install the CLI from npm to use them: npm i -g @autifyhq/aximo-cli");
|
|
19
|
+
}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
6
22
|
}
|
|
@@ -9,16 +9,25 @@ export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
|
|
|
9
9
|
/** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
|
|
10
10
|
export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Fetch the tunnel registry on `port` and return the device udids it has
|
|
13
|
+
* tunnels for, or null when no valid registry answers. The response must parse
|
|
14
|
+
* as a registry (`{"tunnels": {...}}`) — a raw TCP connect is not enough, as an
|
|
15
|
+
* unrelated listener on the port would be mistaken for the tunnel and surface
|
|
16
|
+
* much later as an opaque XCUITest error.
|
|
17
|
+
*
|
|
18
|
+
* The udid list matters because the XCUITest driver (12+) validates the
|
|
19
|
+
* session's udid against this registry whenever it answers, ignoring what
|
|
20
|
+
* devicectl/usbmuxd can see — a running registry without the target device
|
|
21
|
+
* guarantees "Unknown device or simulator UDID".
|
|
15
22
|
*/
|
|
16
|
-
export declare function
|
|
23
|
+
export declare function listTunnelUdids(port?: number, host?: string, timeoutMs?: number): Promise<string[] | null>;
|
|
17
24
|
export interface TunnelScriptOptions {
|
|
18
25
|
appiumHome: string;
|
|
19
26
|
nodePath: string;
|
|
20
27
|
appiumBin: string;
|
|
21
28
|
logFile: string;
|
|
29
|
+
/** Kill any running tunnel-creation daemon first (its registry lacks the target device). */
|
|
30
|
+
restart?: boolean;
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
33
|
* A self-contained shell script that launches the driver's `tunnel-creation` as a
|
|
@@ -36,8 +45,15 @@ export interface EnsureIosTunnelOptions {
|
|
|
36
45
|
appiumBin?: string;
|
|
37
46
|
/** Directory for the generated tunnel script + log (defaults to $AXIMO_HOME/ios-tunnel). */
|
|
38
47
|
dir?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Device udid the registry must have a tunnel for. A running daemon without
|
|
50
|
+
* it (started before the phone was attached, or its creation failed) is
|
|
51
|
+
* restarted — reusing it would guarantee an "Unknown device or simulator
|
|
52
|
+
* UDID" session failure from XCUITest 12+.
|
|
53
|
+
*/
|
|
54
|
+
udid?: string;
|
|
39
55
|
elevate?: Elevator;
|
|
40
|
-
|
|
56
|
+
listUdids?: (port: number) => Promise<string[] | null>;
|
|
41
57
|
writeScript?: (path: string, content: string, dir: string) => void;
|
|
42
58
|
readyTimeoutMs?: number;
|
|
43
59
|
log?: (message: string) => void;
|
|
@@ -46,12 +62,16 @@ export interface EnsureIosTunnelOptions {
|
|
|
46
62
|
export interface EnsureIosTunnelResult {
|
|
47
63
|
reused: boolean;
|
|
48
64
|
started: boolean;
|
|
65
|
+
/** True when a running daemon was replaced because it had no tunnel for `udid`. */
|
|
66
|
+
restarted: boolean;
|
|
49
67
|
skipped: boolean;
|
|
50
68
|
port: number;
|
|
51
69
|
}
|
|
52
70
|
/**
|
|
53
|
-
* Ensure the RemoteXPC tunnel is running
|
|
54
|
-
*
|
|
55
|
-
*
|
|
71
|
+
* Ensure the RemoteXPC tunnel is running — with a tunnel for `udid` when given —
|
|
72
|
+
* so XCUITest can find the device and reach WebDriverAgent. Reuses a running
|
|
73
|
+
* daemon whose registry serves the device (no prompt); restarts one that
|
|
74
|
+
* doesn't; otherwise elevates once via the macOS auth dialog to start it.
|
|
75
|
+
* No-op when AXIMO_IOS_SKIP_TUNNEL is set.
|
|
56
76
|
*/
|
|
57
77
|
export declare function ensureIosTunnel(options?: EnsureIosTunnelOptions): Promise<EnsureIosTunnelResult>;
|