@autifyhq/aximo-cli 0.11.0 → 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 +1 -1
- package/dist/commands/mobile/connect-core.js +1 -1
- package/dist/commands/mobile/doctor.d.ts +10 -3
- package/dist/commands/mobile/doctor.js +31 -12
- package/dist/commands/mobile/doctor.test.js +13 -2
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -546,7 +546,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
|
|
|
546
546
|
|
|
547
547
|
> **On-device steps you can't skip** (Apple requires them once per phone): enable **Developer Mode** (Settings → Privacy & Security → Developer Mode; the phone restarts), tap **Trust This Computer**, and — after the first install of an app signed by your team — **trust the developer certificate** (Settings → General → VPN & Device Management → your "Apple Development" profile → Trust; the phone needs internet). On **free/personal teams** profiles expire every ~7 days; the CLI re-mints them automatically, but a re-mint **resets the certificate trust**, so expect that trust tap to recur weekly. Free teams are also capped at 10 App IDs per week.
|
|
548
548
|
>
|
|
549
|
-
> On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs
|
|
549
|
+
> On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs; it only re-prompts when the running tunnel has no tunnel for your device — e.g. it was started before the phone was attached — and must be restarted). The tunnel deliberately keeps running after `connect` exits; stop it anytime with `sudo pkill -f tunnel-creation`. Devices below iOS 17 skip the tunnel entirely, and Android never uses it. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
|
|
550
550
|
|
|
551
551
|
### Enable USB debugging on the phone
|
|
552
552
|
|
|
@@ -125,7 +125,7 @@ export async function connectWithConfig(config, options) {
|
|
|
125
125
|
console.error(`iOS ${iosMajor} device — skipping the RemoteXPC tunnel (not needed).`);
|
|
126
126
|
}
|
|
127
127
|
else {
|
|
128
|
-
const tunnel = await ensureIosTunnel();
|
|
128
|
+
const tunnel = await ensureIosTunnel({ udid: config.target });
|
|
129
129
|
tunnelRunning = tunnel.started || tunnel.reused;
|
|
130
130
|
if (tunnel.reused)
|
|
131
131
|
console.error("Reusing the running iOS device tunnel.");
|
|
@@ -23,11 +23,18 @@ export interface MobileDoctorReport {
|
|
|
23
23
|
* APKs have wedged instrumentation. Only called when adb itself is available.
|
|
24
24
|
*/
|
|
25
25
|
export declare function checkAndroidDevice(exec?: ExecFn): Promise<Check[]>;
|
|
26
|
-
export
|
|
26
|
+
export interface IosTunnelTarget {
|
|
27
|
+
name: string;
|
|
28
|
+
/** Device udid the registry must have a tunnel for. */
|
|
29
|
+
target: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function checkIosTunnel(listUdids?: (port: number) => Promise<string[] | null>, targets?: IosTunnelTarget[]): Promise<Check>;
|
|
27
32
|
export interface RunMobileDoctorOptions {
|
|
28
33
|
platform?: "ios" | "android" | undefined;
|
|
29
|
-
/** Injectable tunnel probe for tests. */
|
|
30
|
-
|
|
34
|
+
/** Injectable tunnel-registry probe for tests. */
|
|
35
|
+
listTunnelUdids?: (port: number) => Promise<string[] | null>;
|
|
36
|
+
/** Registered iOS devices the tunnel registry should serve (defaults to on-disk configs). */
|
|
37
|
+
iosTunnelTargets?: IosTunnelTarget[];
|
|
31
38
|
/** Injectable adb runner for tests (device-state checks). */
|
|
32
39
|
exec?: ExecFn;
|
|
33
40
|
}
|
|
@@ -10,7 +10,8 @@ import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-se
|
|
|
10
10
|
import { listAndroidDevices } from "../../mobile-control/devices.js";
|
|
11
11
|
import { execBinary } from "../../mobile-control/exec.js";
|
|
12
12
|
import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
|
|
13
|
-
import { DEFAULT_TUNNEL_REGISTRY_PORT,
|
|
13
|
+
import { DEFAULT_TUNNEL_REGISTRY_PORT, listTunnelUdids } from "../../mobile-control/ios-tunnel.js";
|
|
14
|
+
import { listMobileDeviceConfigs } from "./paths.js";
|
|
14
15
|
function which(cmd) {
|
|
15
16
|
const probe = platform() === "win32" ? "where" : "which";
|
|
16
17
|
const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
|
|
@@ -283,21 +284,35 @@ async function probeInstrumentation(serial, exec) {
|
|
|
283
284
|
}
|
|
284
285
|
// The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
|
|
285
286
|
// without a connected device. `connect` starts it automatically, so a missing
|
|
286
|
-
// tunnel is informational, not a failure.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
: {
|
|
287
|
+
// tunnel is informational, not a failure. A running tunnel that lacks a
|
|
288
|
+
// registered device IS a failure mode though: XCUITest 12+ trusts the registry
|
|
289
|
+
// over usbmuxd/devicectl, so such sessions die with "Unknown device or
|
|
290
|
+
// simulator UDID" no matter how visible the phone is.
|
|
291
|
+
export async function checkIosTunnel(listUdids = listTunnelUdids, targets = []) {
|
|
292
|
+
const udids = await listUdids(DEFAULT_TUNNEL_REGISTRY_PORT);
|
|
293
|
+
if (udids === null) {
|
|
294
|
+
return {
|
|
296
295
|
name: "ios tunnel",
|
|
297
296
|
status: "info",
|
|
298
297
|
message: "RemoteXPC tunnel not running",
|
|
299
298
|
hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
|
|
300
299
|
};
|
|
300
|
+
}
|
|
301
|
+
const missing = targets.filter((t) => !udids.includes(t.target));
|
|
302
|
+
if (missing.length > 0) {
|
|
303
|
+
const list = missing.map((m) => `${m.name} (${m.target})`).join(", ");
|
|
304
|
+
return {
|
|
305
|
+
name: "ios tunnel",
|
|
306
|
+
status: "warn",
|
|
307
|
+
message: `RemoteXPC tunnel running but its registry has no tunnel for ${list} — sessions fail with 'Unknown device or simulator UDID'`,
|
|
308
|
+
hint: "Plug the phone in if it isn't connected. If it is, the daemon was likely started before it was attached — `aximo mobile connect` restarts it automatically; manual: sudo pkill -f tunnel-creation, then reconnect.",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
name: "ios tunnel",
|
|
313
|
+
status: "pass",
|
|
314
|
+
message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
315
|
+
};
|
|
301
316
|
}
|
|
302
317
|
export async function runMobileDoctor(options = {}) {
|
|
303
318
|
const checks = [await checkAppium()];
|
|
@@ -312,7 +327,11 @@ export async function runMobileDoctor(options = {}) {
|
|
|
312
327
|
if (options.platform !== "android") {
|
|
313
328
|
checks.push(...(await checkIos()));
|
|
314
329
|
if (platform() === "darwin") {
|
|
315
|
-
|
|
330
|
+
const targets = options.iosTunnelTargets ??
|
|
331
|
+
listMobileDeviceConfigs()
|
|
332
|
+
.filter((c) => c.platform === "ios")
|
|
333
|
+
.map((c) => ({ name: c.name, target: c.target }));
|
|
334
|
+
checks.push(await checkIosTunnel(options.listTunnelUdids, targets));
|
|
316
335
|
}
|
|
317
336
|
}
|
|
318
337
|
const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
@@ -67,12 +67,23 @@ describe("checkAndroidDevice", () => {
|
|
|
67
67
|
});
|
|
68
68
|
describe("checkIosTunnel", () => {
|
|
69
69
|
it("passes when the RemoteXPC tunnel is reachable", async () => {
|
|
70
|
-
const check = await checkIosTunnel(async () =>
|
|
70
|
+
const check = await checkIosTunnel(async () => []);
|
|
71
71
|
expect(check.status).toBe("pass");
|
|
72
72
|
expect(check.name).toBe("ios tunnel");
|
|
73
73
|
});
|
|
74
|
+
it("passes when the registry serves all registered devices", async () => {
|
|
75
|
+
const check = await checkIosTunnel(async () => ["00008030-AAAA"], [{ name: "My iPhone", target: "00008030-AAAA" }]);
|
|
76
|
+
expect(check.status).toBe("pass");
|
|
77
|
+
});
|
|
78
|
+
it("warns when the registry has no tunnel for a registered device", async () => {
|
|
79
|
+
const check = await checkIosTunnel(async () => [], [{ name: "My iPhone", target: "00008030-AAAA" }]);
|
|
80
|
+
expect(check.status).toBe("warn");
|
|
81
|
+
expect(check.message).toContain("My iPhone (00008030-AAAA)");
|
|
82
|
+
expect(check.message).toContain("Unknown device or simulator UDID");
|
|
83
|
+
expect(check.hint).toContain("pkill -f tunnel-creation");
|
|
84
|
+
});
|
|
74
85
|
it("is informational (not a failure) when the tunnel is down", async () => {
|
|
75
|
-
const check = await checkIosTunnel(async () =>
|
|
86
|
+
const check = await checkIosTunnel(async () => null, [{ name: "My iPhone", target: "00008030-AAAA" }]);
|
|
76
87
|
expect(check.status).toBe("info");
|
|
77
88
|
expect(check.hint).toContain("aximo mobile connect");
|
|
78
89
|
});
|
|
@@ -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>;
|
|
@@ -16,22 +16,32 @@ export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
|
|
|
16
16
|
/** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
|
|
17
17
|
export const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Fetch the tunnel registry on `port` and return the device udids it has
|
|
20
|
+
* tunnels for, or null when no valid registry answers. The response must parse
|
|
21
|
+
* as a registry (`{"tunnels": {...}}`) — a raw TCP connect is not enough, as an
|
|
22
|
+
* unrelated listener on the port would be mistaken for the tunnel and surface
|
|
23
|
+
* much later as an opaque XCUITest error.
|
|
24
|
+
*
|
|
25
|
+
* The udid list matters because the XCUITest driver (12+) validates the
|
|
26
|
+
* session's udid against this registry whenever it answers, ignoring what
|
|
27
|
+
* devicectl/usbmuxd can see — a running registry without the target device
|
|
28
|
+
* guarantees "Unknown device or simulator UDID".
|
|
22
29
|
*/
|
|
23
|
-
export async function
|
|
30
|
+
export async function listTunnelUdids(port = DEFAULT_TUNNEL_REGISTRY_PORT, host = "127.0.0.1", timeoutMs = 1500) {
|
|
24
31
|
try {
|
|
25
32
|
const res = await fetch(`http://${host}:${port}/remotexpc/tunnels`, {
|
|
26
33
|
signal: AbortSignal.timeout(timeoutMs),
|
|
27
34
|
});
|
|
28
35
|
if (!res.ok)
|
|
29
|
-
return
|
|
30
|
-
await res.json();
|
|
31
|
-
|
|
36
|
+
return null;
|
|
37
|
+
const body = (await res.json());
|
|
38
|
+
if (!body || typeof body !== "object" || !body.tunnels || typeof body.tunnels !== "object") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return Object.keys(body.tunnels);
|
|
32
42
|
}
|
|
33
43
|
catch {
|
|
34
|
-
return
|
|
44
|
+
return null;
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
/** Single-quote a value for safe embedding in a POSIX shell script. */
|
|
@@ -65,6 +75,9 @@ export function buildTunnelScript(opts) {
|
|
|
65
75
|
"#!/bin/sh",
|
|
66
76
|
`export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
|
|
67
77
|
'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
|
|
78
|
+
// The old daemon must die before the new one binds the registry port. The
|
|
79
|
+
// pattern can't match this script (its cmdline is `/bin/sh <path>`).
|
|
80
|
+
...(opts.restart ? ["pkill -f tunnel-creation 2>/dev/null; sleep 1"] : []),
|
|
68
81
|
`exec perl -e ${shQuote(DAEMONIZE_PERL)} ${shQuote(opts.logFile)} ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation`,
|
|
69
82
|
"",
|
|
70
83
|
].join("\n");
|
|
@@ -91,35 +104,47 @@ const defaultWriteScript = (path, content, dir) => {
|
|
|
91
104
|
mkdirSync(dir, { recursive: true });
|
|
92
105
|
writeFileSync(path, content, { mode: 0o700 });
|
|
93
106
|
};
|
|
94
|
-
async function
|
|
107
|
+
async function pollReady(listUdids, port, udid, timeoutMs, logFile) {
|
|
95
108
|
const deadline = Date.now() + timeoutMs;
|
|
109
|
+
let last = null;
|
|
96
110
|
while (Date.now() < deadline) {
|
|
97
|
-
|
|
111
|
+
last = await listUdids(port);
|
|
112
|
+
if (last !== null && (!udid || last.includes(udid)))
|
|
98
113
|
return;
|
|
99
114
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
100
115
|
}
|
|
116
|
+
if (last !== null && udid) {
|
|
117
|
+
throw new Error(`The iOS device tunnel is running but did not create a tunnel for device ${udid} ` +
|
|
118
|
+
`within ${timeoutMs}ms. Make sure the phone is connected over USB, unlocked, and ` +
|
|
119
|
+
`trusts this Mac, then retry. Check ${logFile} for the daemon's output; stop it ` +
|
|
120
|
+
`with: ${TUNNEL_STOP_COMMAND}`);
|
|
121
|
+
}
|
|
101
122
|
throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
|
|
102
123
|
`Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
|
|
103
124
|
`be running; stop it before retrying: ${TUNNEL_STOP_COMMAND}`);
|
|
104
125
|
}
|
|
105
126
|
/**
|
|
106
|
-
* Ensure the RemoteXPC tunnel is running
|
|
107
|
-
*
|
|
108
|
-
*
|
|
127
|
+
* Ensure the RemoteXPC tunnel is running — with a tunnel for `udid` when given —
|
|
128
|
+
* so XCUITest can find the device and reach WebDriverAgent. Reuses a running
|
|
129
|
+
* daemon whose registry serves the device (no prompt); restarts one that
|
|
130
|
+
* doesn't; otherwise elevates once via the macOS auth dialog to start it.
|
|
131
|
+
* No-op when AXIMO_IOS_SKIP_TUNNEL is set.
|
|
109
132
|
*/
|
|
110
133
|
export async function ensureIosTunnel(options = {}) {
|
|
111
134
|
const env = options.env ?? process.env;
|
|
112
135
|
const port = options.port ?? DEFAULT_TUNNEL_REGISTRY_PORT;
|
|
113
|
-
const
|
|
136
|
+
const listUdids = options.listUdids ?? ((p) => listTunnelUdids(p));
|
|
114
137
|
const log = options.log ?? ((m) => console.error(m));
|
|
115
138
|
if (envFlag(env, "AXIMO_IOS_SKIP_TUNNEL")) {
|
|
116
|
-
return { reused: false, started: false, skipped: true, port };
|
|
139
|
+
return { reused: false, started: false, restarted: false, skipped: true, port };
|
|
117
140
|
}
|
|
118
|
-
// A
|
|
119
|
-
// most once per boot rather than once per `connect`.
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
// A tunnel serving the device is reused as-is — this is what makes the prompt
|
|
142
|
+
// appear at most once per boot rather than once per `connect`.
|
|
143
|
+
const existing = await listUdids(port);
|
|
144
|
+
if (existing !== null && (!options.udid || existing.includes(options.udid))) {
|
|
145
|
+
return { reused: true, started: false, restarted: false, skipped: false, port };
|
|
122
146
|
}
|
|
147
|
+
const restarting = existing !== null;
|
|
123
148
|
const appiumHome = options.appiumHome ?? getAppiumHome();
|
|
124
149
|
const nodePath = options.nodePath ?? process.execPath;
|
|
125
150
|
const appiumBin = options.appiumBin ?? resolveAppiumBin();
|
|
@@ -127,8 +152,12 @@ export async function ensureIosTunnel(options = {}) {
|
|
|
127
152
|
const scriptPath = join(dir, "tunnel.sh");
|
|
128
153
|
const logFile = join(dir, "tunnel.log");
|
|
129
154
|
const writeScript = options.writeScript ?? defaultWriteScript;
|
|
130
|
-
writeScript(scriptPath, buildTunnelScript({ appiumHome, nodePath, appiumBin, logFile }), dir);
|
|
131
|
-
log(
|
|
155
|
+
writeScript(scriptPath, buildTunnelScript({ appiumHome, nodePath, appiumBin, logFile, restart: restarting }), dir);
|
|
156
|
+
log(restarting
|
|
157
|
+
? `The running iOS device tunnel has no tunnel for this device (${options.udid}) — ` +
|
|
158
|
+
"sessions would fail with 'Unknown device or simulator UDID'. Restarting it " +
|
|
159
|
+
"(needs admin). Approve the macOS password / Touch ID prompt…"
|
|
160
|
+
: "Starting the iOS device tunnel (needs admin). Approve the macOS password / Touch ID prompt…");
|
|
132
161
|
const elevate = options.elevate ?? osascriptElevate;
|
|
133
162
|
try {
|
|
134
163
|
await elevate(scriptPath);
|
|
@@ -136,15 +165,22 @@ export async function ensureIosTunnel(options = {}) {
|
|
|
136
165
|
catch (err) {
|
|
137
166
|
const detail = err instanceof Error ? err.message : String(err);
|
|
138
167
|
const declined = /User canceled|-128/i.test(detail);
|
|
168
|
+
const verb = restarting ? "restarted" : "started";
|
|
139
169
|
const lead = declined
|
|
140
|
-
?
|
|
141
|
-
: `Could not start the iOS device tunnel: ${detail}.`;
|
|
170
|
+
? `Admin approval was declined, so the iOS device tunnel wasn't ${verb}.`
|
|
171
|
+
: `Could not ${restarting ? "restart" : "start"} the iOS device tunnel: ${detail}.`;
|
|
172
|
+
// In the restart path the stale daemon still holds the registry port, so the
|
|
173
|
+
// manual command must include the stop step or it fails to bind.
|
|
174
|
+
const manual = restarting ? `stop the stale daemon first (${TUNNEL_STOP_COMMAND}), then ` : "";
|
|
142
175
|
throw new Error(`${lead} It is required for iOS 17+ to reach WebDriverAgent. Approve the macOS prompt and ` +
|
|
143
|
-
`retry, or start it yourself: sudo APPIUM_HOME=${shQuote(appiumHome)}
|
|
144
|
-
`${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
|
|
176
|
+
`retry, or ${manual}start it yourself: sudo APPIUM_HOME=${shQuote(appiumHome)} ` +
|
|
177
|
+
`${shQuote(nodePath)} ${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
|
|
145
178
|
}
|
|
146
|
-
|
|
179
|
+
// Per-device tunnel creation (lockdown, CoreDeviceProxy, RSD catalog discovery)
|
|
180
|
+
// takes 30-60s on top of daemon boot, so the device wait gets a longer default.
|
|
181
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? (options.udid ? 90_000 : 45_000);
|
|
182
|
+
await pollReady(listUdids, port, options.udid, readyTimeoutMs, logFile);
|
|
147
183
|
log(`iOS device tunnel is up (registry on port ${port}). It keeps running in the background ` +
|
|
148
184
|
`so later connects don't prompt again; stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
|
|
149
|
-
return { reused: false, started: true, skipped: false, port };
|
|
185
|
+
return { reused: false, started: true, restarted: restarting, skipped: false, port };
|
|
150
186
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel, listTunnelUdids, } from "./ios-tunnel.js";
|
|
3
4
|
describe("buildTunnelScript", () => {
|
|
4
5
|
it("launches tunnel-creation as a setsid-detached daemon with APPIUM_HOME set", () => {
|
|
5
6
|
const script = buildTunnelScript({
|
|
@@ -26,6 +27,63 @@ describe("buildTunnelScript", () => {
|
|
|
26
27
|
});
|
|
27
28
|
expect(script).toContain("export APPIUM_HOME='/home/o'\\''brien/.aximo'");
|
|
28
29
|
});
|
|
30
|
+
it("kills the old daemon before starting when restart is set", () => {
|
|
31
|
+
const opts = {
|
|
32
|
+
appiumHome: "/ah",
|
|
33
|
+
nodePath: "/node",
|
|
34
|
+
appiumBin: "/appium",
|
|
35
|
+
logFile: "/l.log",
|
|
36
|
+
};
|
|
37
|
+
const restart = buildTunnelScript({ ...opts, restart: true });
|
|
38
|
+
expect(restart).toContain("pkill -f tunnel-creation");
|
|
39
|
+
// pkill must come before the daemon launch, not after.
|
|
40
|
+
expect(restart.indexOf("pkill")).toBeLessThan(restart.indexOf("exec perl"));
|
|
41
|
+
expect(buildTunnelScript(opts)).not.toContain("pkill");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("listTunnelUdids", () => {
|
|
45
|
+
let server;
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
const open = server;
|
|
48
|
+
server = undefined;
|
|
49
|
+
if (open)
|
|
50
|
+
await new Promise((resolve) => open.close(() => resolve()));
|
|
51
|
+
});
|
|
52
|
+
const listen = (handler) => new Promise((resolve) => {
|
|
53
|
+
server = createServer(handler).listen(0, "127.0.0.1", () => {
|
|
54
|
+
const address = server?.address();
|
|
55
|
+
resolve(typeof address === "object" && address ? address.port : 0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it("returns the udids the registry has tunnels for", async () => {
|
|
59
|
+
const port = await listen((req, res) => {
|
|
60
|
+
expect(req.url).toBe("/remotexpc/tunnels");
|
|
61
|
+
res.setHeader("content-type", "application/json");
|
|
62
|
+
res.end(JSON.stringify({ tunnels: { "00008030-AAAA": {}, "00008130-BBBB": {} } }));
|
|
63
|
+
});
|
|
64
|
+
await expect(listTunnelUdids(port)).resolves.toEqual(["00008030-AAAA", "00008130-BBBB"]);
|
|
65
|
+
});
|
|
66
|
+
it("returns null when nothing listens on the port", async () => {
|
|
67
|
+
const port = await listen((_req, res) => res.end());
|
|
68
|
+
const closed = new Promise((resolve) => server?.close(() => resolve()));
|
|
69
|
+
await closed;
|
|
70
|
+
server = undefined;
|
|
71
|
+
await expect(listTunnelUdids(port)).resolves.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it("returns null when the listener is not a tunnel registry", async () => {
|
|
74
|
+
const port = await listen((_req, res) => {
|
|
75
|
+
res.setHeader("content-type", "application/json");
|
|
76
|
+
res.end(JSON.stringify({ hello: "world" }));
|
|
77
|
+
});
|
|
78
|
+
await expect(listTunnelUdids(port)).resolves.toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it("returns null on a non-JSON or error response", async () => {
|
|
81
|
+
const port = await listen((_req, res) => {
|
|
82
|
+
res.statusCode = 500;
|
|
83
|
+
res.end("boom");
|
|
84
|
+
});
|
|
85
|
+
await expect(listTunnelUdids(port)).resolves.toBeNull();
|
|
86
|
+
});
|
|
29
87
|
});
|
|
30
88
|
describe("ensureIosTunnel", () => {
|
|
31
89
|
const stubs = () => ({
|
|
@@ -36,35 +94,73 @@ describe("ensureIosTunnel", () => {
|
|
|
36
94
|
it("reuses a healthy tunnel without prompting", async () => {
|
|
37
95
|
const s = stubs();
|
|
38
96
|
const result = await ensureIosTunnel({
|
|
39
|
-
|
|
97
|
+
listUdids: async () => [],
|
|
40
98
|
...s,
|
|
41
99
|
env: {},
|
|
42
100
|
});
|
|
43
101
|
expect(result).toEqual({
|
|
44
102
|
reused: true,
|
|
45
103
|
started: false,
|
|
104
|
+
restarted: false,
|
|
46
105
|
skipped: false,
|
|
47
106
|
port: DEFAULT_TUNNEL_REGISTRY_PORT,
|
|
48
107
|
});
|
|
49
108
|
expect(s.elevate).not.toHaveBeenCalled();
|
|
50
109
|
expect(s.writeScript).not.toHaveBeenCalled();
|
|
51
110
|
});
|
|
111
|
+
it("reuses a tunnel that serves the requested device", async () => {
|
|
112
|
+
const s = stubs();
|
|
113
|
+
const result = await ensureIosTunnel({
|
|
114
|
+
udid: "00008030-AAAA",
|
|
115
|
+
listUdids: async () => ["00008030-AAAA"],
|
|
116
|
+
...s,
|
|
117
|
+
env: {},
|
|
118
|
+
});
|
|
119
|
+
expect(result.reused).toBe(true);
|
|
120
|
+
expect(s.elevate).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
it("restarts a running tunnel whose registry lacks the device", async () => {
|
|
123
|
+
const s = stubs();
|
|
124
|
+
const listUdids = vi
|
|
125
|
+
.fn()
|
|
126
|
+
.mockResolvedValueOnce([]) // pre-check: registry up, no tunnel for the phone
|
|
127
|
+
.mockResolvedValue(["00008030-AAAA"]); // poll after restart
|
|
128
|
+
const result = await ensureIosTunnel({
|
|
129
|
+
udid: "00008030-AAAA",
|
|
130
|
+
listUdids,
|
|
131
|
+
appiumHome: "/ah",
|
|
132
|
+
nodePath: "/node",
|
|
133
|
+
appiumBin: "/appium",
|
|
134
|
+
dir: "/tun",
|
|
135
|
+
readyTimeoutMs: 2000,
|
|
136
|
+
...s,
|
|
137
|
+
env: {},
|
|
138
|
+
});
|
|
139
|
+
expect(result.restarted).toBe(true);
|
|
140
|
+
expect(result.started).toBe(true);
|
|
141
|
+
expect(result.reused).toBe(false);
|
|
142
|
+
expect(s.elevate).toHaveBeenCalledWith("/tun/tunnel.sh");
|
|
143
|
+
// The elevated script must replace the old daemon, not race it for the port.
|
|
144
|
+
const script = s.writeScript.mock.calls[0]?.[1];
|
|
145
|
+
expect(script).toContain("pkill -f tunnel-creation");
|
|
146
|
+
expect(s.log).toHaveBeenCalledWith(expect.stringContaining("no tunnel for this device"));
|
|
147
|
+
});
|
|
52
148
|
it("is a no-op when AXIMO_IOS_SKIP_TUNNEL is set", async () => {
|
|
53
149
|
const s = stubs();
|
|
54
|
-
const
|
|
150
|
+
const listUdids = vi.fn(async () => null);
|
|
55
151
|
const result = await ensureIosTunnel({
|
|
56
|
-
|
|
152
|
+
listUdids,
|
|
57
153
|
...s,
|
|
58
154
|
env: { AXIMO_IOS_SKIP_TUNNEL: "1" },
|
|
59
155
|
});
|
|
60
156
|
expect(result.skipped).toBe(true);
|
|
61
|
-
expect(
|
|
157
|
+
expect(listUdids).not.toHaveBeenCalled();
|
|
62
158
|
expect(s.elevate).not.toHaveBeenCalled();
|
|
63
159
|
});
|
|
64
160
|
it('does not skip for AXIMO_IOS_SKIP_TUNNEL="0"', async () => {
|
|
65
161
|
const s = stubs();
|
|
66
162
|
const result = await ensureIosTunnel({
|
|
67
|
-
|
|
163
|
+
listUdids: async () => [],
|
|
68
164
|
...s,
|
|
69
165
|
env: { AXIMO_IOS_SKIP_TUNNEL: "0" },
|
|
70
166
|
});
|
|
@@ -74,7 +170,7 @@ describe("ensureIosTunnel", () => {
|
|
|
74
170
|
it("points at the tunnel log when the daemon never becomes healthy", async () => {
|
|
75
171
|
const s = stubs();
|
|
76
172
|
await expect(ensureIosTunnel({
|
|
77
|
-
|
|
173
|
+
listUdids: async () => null,
|
|
78
174
|
appiumHome: "/ah",
|
|
79
175
|
nodePath: "/node",
|
|
80
176
|
appiumBin: "/appium",
|
|
@@ -84,14 +180,40 @@ describe("ensureIosTunnel", () => {
|
|
|
84
180
|
env: {},
|
|
85
181
|
})).rejects.toThrow(/tunnel\.log.*pkill -f tunnel-creation/s);
|
|
86
182
|
});
|
|
183
|
+
it("explains when the registry never creates a tunnel for the device", async () => {
|
|
184
|
+
const s = stubs();
|
|
185
|
+
await expect(ensureIosTunnel({
|
|
186
|
+
udid: "00008030-AAAA",
|
|
187
|
+
listUdids: async () => null,
|
|
188
|
+
appiumHome: "/ah",
|
|
189
|
+
nodePath: "/node",
|
|
190
|
+
appiumBin: "/appium",
|
|
191
|
+
dir: "/tun",
|
|
192
|
+
readyTimeoutMs: 700,
|
|
193
|
+
...s,
|
|
194
|
+
env: {},
|
|
195
|
+
})).rejects.toThrow(/did not become ready/);
|
|
196
|
+
const s2 = stubs();
|
|
197
|
+
await expect(ensureIosTunnel({
|
|
198
|
+
udid: "00008030-AAAA",
|
|
199
|
+
listUdids: async () => ["00008130-OTHER"],
|
|
200
|
+
appiumHome: "/ah",
|
|
201
|
+
nodePath: "/node",
|
|
202
|
+
appiumBin: "/appium",
|
|
203
|
+
dir: "/tun",
|
|
204
|
+
readyTimeoutMs: 700,
|
|
205
|
+
...s2,
|
|
206
|
+
env: {},
|
|
207
|
+
})).rejects.toThrow(/did not create a tunnel for device 00008030-AAAA.*connected over USB/s);
|
|
208
|
+
});
|
|
87
209
|
it("elevates and starts the tunnel when none is running", async () => {
|
|
88
210
|
const s = stubs();
|
|
89
|
-
const
|
|
211
|
+
const listUdids = vi
|
|
90
212
|
.fn()
|
|
91
|
-
.mockResolvedValueOnce(
|
|
92
|
-
.mockResolvedValue(
|
|
213
|
+
.mockResolvedValueOnce(null) // pre-check
|
|
214
|
+
.mockResolvedValue([]); // poll after elevate
|
|
93
215
|
const result = await ensureIosTunnel({
|
|
94
|
-
|
|
216
|
+
listUdids,
|
|
95
217
|
appiumHome: "/ah",
|
|
96
218
|
nodePath: "/node",
|
|
97
219
|
appiumBin: "/appium",
|
|
@@ -101,20 +223,37 @@ describe("ensureIosTunnel", () => {
|
|
|
101
223
|
env: {},
|
|
102
224
|
});
|
|
103
225
|
expect(result.started).toBe(true);
|
|
226
|
+
expect(result.restarted).toBe(false);
|
|
104
227
|
expect(s.writeScript).toHaveBeenCalledOnce();
|
|
105
228
|
expect(s.elevate).toHaveBeenCalledWith("/tun/tunnel.sh");
|
|
229
|
+
const script = s.writeScript.mock.calls[0]?.[1];
|
|
230
|
+
expect(script).not.toContain("pkill");
|
|
106
231
|
});
|
|
107
232
|
it("throws actionable guidance when the admin prompt is declined", async () => {
|
|
108
233
|
const s = stubs();
|
|
109
234
|
s.elevate.mockRejectedValueOnce(new Error("User canceled."));
|
|
110
235
|
await expect(ensureIosTunnel({
|
|
111
|
-
|
|
236
|
+
listUdids: async () => null,
|
|
237
|
+
appiumHome: "/ah",
|
|
238
|
+
nodePath: "/node",
|
|
239
|
+
appiumBin: "/appium",
|
|
240
|
+
dir: "/tun",
|
|
241
|
+
...s,
|
|
242
|
+
env: {},
|
|
243
|
+
})).rejects.toThrow(/Admin approval was declined.*wasn't started.*driver run xcuitest tunnel-creation/s);
|
|
244
|
+
});
|
|
245
|
+
it("tells the user to stop the stale daemon when a declined restart leaves it running", async () => {
|
|
246
|
+
const s = stubs();
|
|
247
|
+
s.elevate.mockRejectedValueOnce(new Error("User canceled."));
|
|
248
|
+
await expect(ensureIosTunnel({
|
|
249
|
+
udid: "00008030-AAAA",
|
|
250
|
+
listUdids: async () => [],
|
|
112
251
|
appiumHome: "/ah",
|
|
113
252
|
nodePath: "/node",
|
|
114
253
|
appiumBin: "/appium",
|
|
115
254
|
dir: "/tun",
|
|
116
255
|
...s,
|
|
117
256
|
env: {},
|
|
118
|
-
})).rejects.toThrow(/
|
|
257
|
+
})).rejects.toThrow(/wasn't restarted.*stop the stale daemon first \(sudo pkill -f tunnel-creation\), then start it yourself/s);
|
|
119
258
|
});
|
|
120
259
|
});
|
|
@@ -41,6 +41,12 @@ const IOS_RULES = [
|
|
|
41
41
|
test: /Connection refused to port \d+|Tunnel registry port not found|RemoteXPC|tunnel is not available/i,
|
|
42
42
|
hint: "The iOS device tunnel isn't reachable. `aximo mobile connect` starts it (needs admin); confirm with `aximo mobile doctor`. Set AXIMO_IOS_SKIP_TUNNEL only if you manage it yourself.",
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
// XCUITest 12+ trusts the tunnel registry over usbmuxd/devicectl when it
|
|
46
|
+
// answers, so a registry without this device fails even a visible phone.
|
|
47
|
+
test: /Unknown device or simulator UDID/i,
|
|
48
|
+
hint: "Appium's device list did not include this phone. If it shows in `xcrun devicectl list devices`, the running tunnel daemon has no tunnel for it — re-run `aximo mobile connect` (it restarts the daemon), or stop it first: sudo pkill -f tunnel-creation. Otherwise reconnect USB (data cable, no hub), unlock the phone, and re-trust this Mac.",
|
|
49
|
+
},
|
|
44
50
|
{
|
|
45
51
|
// "is locked" (not "is unlocked") plus XCUITest's "could not be, unlocked" phrasing.
|
|
46
52
|
test: /(device|screen).{0,30}\bis locked|could not be,? unlocked|passcode/i,
|
|
@@ -19,6 +19,9 @@ describe("mobileErrorHint (iOS)", () => {
|
|
|
19
19
|
it("matches a refused connection on a dynamic wdaLocalPort", () => {
|
|
20
20
|
expect(mobileErrorHint("Connection refused to port 51234", "ios")).toContain("device tunnel");
|
|
21
21
|
});
|
|
22
|
+
it("explains the stale tunnel registry behind 'Unknown device or simulator UDID'", () => {
|
|
23
|
+
expect(mobileErrorHint("Unknown device or simulator UDID: '00008030-001D78CC2653402E'", "ios")).toContain("tunnel daemon has no tunnel for it");
|
|
24
|
+
});
|
|
22
25
|
it("matches the driver's RemoteXPC tunnel errors", () => {
|
|
23
26
|
expect(mobileErrorHint("RemoteXPC tunnel is not available for this session", "ios")).toContain("device tunnel");
|
|
24
27
|
});
|