@autifyhq/aximo-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/commands/mobile/connect-core.js +9 -4
- package/dist/commands/mobile/connect.js +4 -3
- package/dist/commands/mobile/connect.test.js +7 -1
- package/dist/commands/mobile/doctor.d.ts +1 -0
- package/dist/commands/mobile/doctor.js +18 -2
- package/dist/commands/mobile/doctor.test.js +15 -1
- package/dist/commands/mobile/install.d.ts +1 -0
- package/dist/commands/mobile/install.js +14 -3
- package/dist/commands/mobile/list.d.ts +1 -0
- package/dist/commands/mobile/list.js +23 -3
- package/dist/commands/mobile/list.test.d.ts +1 -0
- package/dist/commands/mobile/list.test.js +24 -0
- package/dist/commands/mobile/output.d.ts +13 -0
- package/dist/commands/mobile/output.js +21 -0
- package/dist/commands/mobile/output.test.d.ts +1 -0
- package/dist/commands/mobile/output.test.js +54 -0
- package/dist/commands/mobile/register.d.ts +1 -0
- package/dist/commands/mobile/register.js +11 -3
- package/dist/commands/mobile/setup.d.ts +1 -0
- package/dist/commands/mobile/setup.js +7 -2
- package/dist/commands/mobile/unregister.d.ts +7 -3
- package/dist/commands/mobile/unregister.js +10 -4
- package/dist/mobile-control/exec.d.ts +5 -1
- package/dist/mobile-control/exec.js +2 -2
- package/dist/mobile-control/ios-tunnel.d.ts +5 -2
- package/dist/mobile-control/ios-tunnel.js +25 -5
- package/dist/mobile-control/ios-tunnel.test.js +8 -3
- package/dist/mobile-control/ios-wda-preinstall.js +6 -3
- package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
- package/dist/mobile-control/tunnel.js +34 -1
- package/dist/mobile-control/tunnel.test.js +82 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ aximo session get SESSION_ID
|
|
|
34
34
|
|
|
35
35
|
Get your API key from **Settings → API Keys** in the [Aximo dashboard](https://aximo.autify.com).
|
|
36
36
|
|
|
37
|
+
Want to run tests on **your own hardware** instead of the cloud? See [Device (BYOD)](#device-byod--bring-your-own-device) to register this desktop (Windows/macOS/Linux), or [Mobile](#mobile-local-android-phone-or-iphone) to drive a USB-connected Android phone or iPhone — each section starts with the exact prerequisites for a fresh machine.
|
|
38
|
+
|
|
37
39
|
## Configuration
|
|
38
40
|
|
|
39
41
|
Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
|
|
@@ -208,9 +210,9 @@ Terminology note: **`aximo device list` returns mobile devices** from Device Far
|
|
|
208
210
|
|
|
209
211
|
### Prerequisites
|
|
210
212
|
|
|
211
|
-
- **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
|
|
213
|
+
- **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
|
|
212
214
|
- **An Aximo API key**, configured via `aximo configure` or env vars.
|
|
213
|
-
- **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run.
|
|
215
|
+
- **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run. On **macOS**, grant the terminal **Accessibility** and **Screen Recording** permissions (System Settings → Privacy & Security) — without them actions silently do nothing.
|
|
214
216
|
- **Outbound WebSocket to port 443** — most corporate firewalls allow this; if yours doesn't, see [Troubleshooting](#byod-troubleshooting).
|
|
215
217
|
|
|
216
218
|
> Python is **not** a prerequisite. On first `connect` (or via `device setup`), the CLI downloads a self-contained CPython build into `$AXIMO_HOME/python/` (~35 MB download, ~130 MB installed) and installs the required packages. Your system Python is untouched.
|
|
@@ -411,7 +413,7 @@ Terminology note: **`aximo mobile list`** shows phones plugged into _this_ machi
|
|
|
411
413
|
|
|
412
414
|
### Prerequisites
|
|
413
415
|
|
|
414
|
-
- **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
|
|
416
|
+
- **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
|
|
415
417
|
- **An Aximo API key**, configured via `aximo configure` or env vars.
|
|
416
418
|
- **A USB-connected phone**:
|
|
417
419
|
- **Android** — **USB debugging** enabled and authorized — see [Enable USB debugging on the phone](#enable-usb-debugging-on-the-phone).
|
|
@@ -429,12 +431,13 @@ You only need **`adb`** and the **Android SDK** on the machine running `aximo mo
|
|
|
429
431
|
**macOS**
|
|
430
432
|
|
|
431
433
|
```bash
|
|
432
|
-
# Option A — standalone
|
|
433
|
-
|
|
434
|
+
# Option A — standalone SDK Platform-Tools (no Android Studio): download the zip from
|
|
435
|
+
# https://developer.android.com/tools/releases/platform-tools and unzip it so adb ends up at
|
|
436
|
+
# ~/Library/Android/sdk/platform-tools/adb (the driver needs this SDK layout, not just adb on PATH)
|
|
434
437
|
# Option B — full Android Studio (also installs the SDK):
|
|
435
438
|
brew install --cask android-studio
|
|
436
439
|
|
|
437
|
-
# Point ANDROID_HOME at the SDK (
|
|
440
|
+
# Point ANDROID_HOME at the SDK (both options above use this location):
|
|
438
441
|
echo 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrc
|
|
439
442
|
echo 'export PATH="$PATH:$ANDROID_HOME/platform-tools"' >> ~/.zshrc
|
|
440
443
|
source ~/.zshrc
|
|
@@ -496,7 +499,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
|
|
|
496
499
|
|
|
497
500
|
> **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.
|
|
498
501
|
>
|
|
499
|
-
> 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, so at most once per boot). Devices below iOS 17 skip the tunnel entirely. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
|
|
502
|
+
> 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, so at most once per boot). 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).
|
|
500
503
|
|
|
501
504
|
### Enable USB debugging on the phone
|
|
502
505
|
|
|
@@ -5,7 +5,7 @@ import { installUploadedApp } from "../../mobile-control/app-install.js";
|
|
|
5
5
|
import { pickFreePort, startAppiumServer } from "../../mobile-control/appium-server.js";
|
|
6
6
|
import { withBundleIdTranslation } from "../../mobile-control/bundle-id-map.js";
|
|
7
7
|
import { getIosDeviceInfo, iosMajorVersion } from "../../mobile-control/ios-devicectl.js";
|
|
8
|
-
import { ensureIosTunnel } from "../../mobile-control/ios-tunnel.js";
|
|
8
|
+
import { ensureIosTunnel, TUNNEL_STOP_COMMAND } from "../../mobile-control/ios-tunnel.js";
|
|
9
9
|
import { resolveIosSigning, resolveIosSigningAuto, } from "../../mobile-control/ios-signing.js";
|
|
10
10
|
import { resolveWdaCaching } from "../../mobile-control/ios-wda-cache.js";
|
|
11
11
|
import { resolvePreinstalledWdaStrategy } from "../../mobile-control/ios-wda-preinstall.js";
|
|
@@ -28,9 +28,8 @@ export function resolveDeviceConfig(deviceId) {
|
|
|
28
28
|
return formatError("validation_error", 'No registered devices. Register one first: aximo mobile register --name "My Pixel" --platform android', false);
|
|
29
29
|
}
|
|
30
30
|
if (all.length > 1) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.join(", ")}`, false);
|
|
31
|
+
const lines = all.map((c) => ` - ${c.name} (${c.platform}) — device-id: ${c.deviceId}`);
|
|
32
|
+
return formatError("validation_error", `Multiple devices are registered on this machine; pass --device-id to pick one:\n${lines.join("\n")}`, false, `Re-run with --device-id, e.g. --device-id ${all[0]?.deviceId}`);
|
|
34
33
|
}
|
|
35
34
|
const only = all[0];
|
|
36
35
|
if (!only) {
|
|
@@ -90,6 +89,7 @@ async function preflightIosDevice(udid) {
|
|
|
90
89
|
export async function connectWithConfig(config) {
|
|
91
90
|
await assertPrerequisites(config.platform);
|
|
92
91
|
let iosMajor = null;
|
|
92
|
+
let tunnelRunning = false;
|
|
93
93
|
if (config.platform === "ios") {
|
|
94
94
|
iosMajor = await preflightIosDevice(config.target);
|
|
95
95
|
// Physical iPhones on iOS 17+ need the RemoteXPC tunnel before XCUITest can
|
|
@@ -100,6 +100,7 @@ export async function connectWithConfig(config) {
|
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
102
102
|
const tunnel = await ensureIosTunnel();
|
|
103
|
+
tunnelRunning = tunnel.started || tunnel.reused;
|
|
103
104
|
if (tunnel.reused)
|
|
104
105
|
console.error("Reusing the running iOS device tunnel.");
|
|
105
106
|
}
|
|
@@ -182,5 +183,9 @@ export async function connectWithConfig(config) {
|
|
|
182
183
|
finally {
|
|
183
184
|
await controller.close();
|
|
184
185
|
await server.stop();
|
|
186
|
+
if (tunnelRunning) {
|
|
187
|
+
console.error("The iOS device tunnel is still running in the background (admin-started; the next " +
|
|
188
|
+
`connect won't prompt again). Stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
|
|
189
|
+
}
|
|
185
190
|
}
|
|
186
191
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
|
|
3
|
-
import { formatError
|
|
3
|
+
import { formatError } from "../../output.js";
|
|
4
4
|
import { connectWithConfig, resolveDeviceConfig } from "./connect-core.js";
|
|
5
|
+
import { printMobileResult } from "./output.js";
|
|
5
6
|
const IOS_PHYSICAL_WARNING = "Driving a physical iPhone goes through Appium's XCUITest driver, which builds and " +
|
|
6
7
|
"installs a WebDriverAgent runner on the device. The device must have Developer Mode " +
|
|
7
8
|
"enabled (Settings → Privacy & Security) and be trusted, and WDA needs an Apple Developer " +
|
|
@@ -20,7 +21,7 @@ export default defineCommand({
|
|
|
20
21
|
async run({ args }) {
|
|
21
22
|
const resolved = resolveDeviceConfig(args["device-id"]);
|
|
22
23
|
if (!resolved.ok) {
|
|
23
|
-
|
|
24
|
+
printMobileResult(resolved);
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
const warning = iosPhysicalWarning(resolved.config);
|
|
@@ -31,7 +32,7 @@ export default defineCommand({
|
|
|
31
32
|
}
|
|
32
33
|
catch (error) {
|
|
33
34
|
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
-
|
|
35
|
+
printMobileResult(formatError("api_error", message, true, mobileErrorHint(message, resolved.config.platform)));
|
|
35
36
|
}
|
|
36
37
|
},
|
|
37
38
|
});
|
|
@@ -61,7 +61,7 @@ describe("resolveDeviceConfig", () => {
|
|
|
61
61
|
if (result.ok)
|
|
62
62
|
expect(result.config.deviceId).toBe("only-1");
|
|
63
63
|
});
|
|
64
|
-
it("requires --device-id when multiple are registered", () => {
|
|
64
|
+
it("requires --device-id when multiple are registered, listing each candidate", () => {
|
|
65
65
|
for (const id of ["a", "b"]) {
|
|
66
66
|
writeMobileDeviceConfig({
|
|
67
67
|
deviceId: id,
|
|
@@ -73,6 +73,12 @@ describe("resolveDeviceConfig", () => {
|
|
|
73
73
|
}
|
|
74
74
|
const ambiguous = resolveDeviceConfig(undefined);
|
|
75
75
|
expect(ambiguous.ok).toBe(false);
|
|
76
|
+
if (!ambiguous.ok) {
|
|
77
|
+
expect(ambiguous.error.message).toContain("pass --device-id");
|
|
78
|
+
expect(ambiguous.error.message).toContain("device-id: a");
|
|
79
|
+
expect(ambiguous.error.message).toContain("device-id: b");
|
|
80
|
+
expect(ambiguous.error.hint).toContain("--device-id");
|
|
81
|
+
}
|
|
76
82
|
const picked = resolveDeviceConfig("b");
|
|
77
83
|
expect(picked.ok).toBe(true);
|
|
78
84
|
if (picked.ok)
|
|
@@ -22,6 +22,7 @@ export interface RunMobileDoctorOptions {
|
|
|
22
22
|
isTunnelHealthy?: (port: number) => Promise<boolean>;
|
|
23
23
|
}
|
|
24
24
|
export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
|
|
25
|
+
export declare function renderDoctorReport(data: MobileDoctorReport): string;
|
|
25
26
|
declare const _default: import("citty").CommandDef<{
|
|
26
27
|
readonly platform: {
|
|
27
28
|
readonly type: "string";
|
|
@@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { platform } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { formatSuccess
|
|
6
|
+
import { formatSuccess } from "../../output.js";
|
|
7
|
+
import { printMobileResult } from "./output.js";
|
|
7
8
|
import { importAppium } from "../../mobile-control/appium-import.js";
|
|
8
9
|
import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
|
|
9
10
|
import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
|
|
@@ -171,6 +172,21 @@ export async function runMobileDoctor(options = {}) {
|
|
|
171
172
|
summary[c.status]++;
|
|
172
173
|
return formatSuccess({ checks, summary });
|
|
173
174
|
}
|
|
175
|
+
const STATUS_MARK = {
|
|
176
|
+
pass: "✓",
|
|
177
|
+
warn: "!",
|
|
178
|
+
fail: "✗",
|
|
179
|
+
info: "i",
|
|
180
|
+
};
|
|
181
|
+
export function renderDoctorReport(data) {
|
|
182
|
+
const lines = data.checks.map((c) => {
|
|
183
|
+
const line = `${STATUS_MARK[c.status]} ${c.name}: ${c.message}`;
|
|
184
|
+
return c.hint ? `${line}\n ${c.hint}` : line;
|
|
185
|
+
});
|
|
186
|
+
const s = data.summary;
|
|
187
|
+
lines.push(`Summary: ${s.pass} pass, ${s.warn} warn, ${s.fail} fail, ${s.info} info`);
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
|
174
190
|
export default defineCommand({
|
|
175
191
|
meta: {
|
|
176
192
|
name: "doctor",
|
|
@@ -184,7 +200,7 @@ export default defineCommand({
|
|
|
184
200
|
if (args.platform === "ios" || args.platform === "android")
|
|
185
201
|
options.platform = args.platform;
|
|
186
202
|
const result = await runMobileDoctor(options);
|
|
187
|
-
|
|
203
|
+
printMobileResult(result, { render: renderDoctorReport });
|
|
188
204
|
if (result.ok && result.data.summary.fail > 0) {
|
|
189
205
|
process.exitCode = 1;
|
|
190
206
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { checkIosTunnel } from "./doctor.js";
|
|
2
|
+
import { checkIosTunnel, renderDoctorReport } from "./doctor.js";
|
|
3
3
|
describe("checkIosTunnel", () => {
|
|
4
4
|
it("passes when the RemoteXPC tunnel is reachable", async () => {
|
|
5
5
|
const check = await checkIosTunnel(async () => true);
|
|
@@ -12,3 +12,17 @@ describe("checkIosTunnel", () => {
|
|
|
12
12
|
expect(check.hint).toContain("aximo mobile connect");
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
|
+
describe("renderDoctorReport", () => {
|
|
16
|
+
it("renders one line per check with status marks, indented hints, and a summary", () => {
|
|
17
|
+
const text = renderDoctorReport({
|
|
18
|
+
checks: [
|
|
19
|
+
{ name: "appium", status: "pass", message: "bundled Appium found" },
|
|
20
|
+
{ name: "adb", status: "fail", message: "adb not found", hint: "install platform-tools" },
|
|
21
|
+
],
|
|
22
|
+
summary: { pass: 1, warn: 0, fail: 1, info: 0 },
|
|
23
|
+
});
|
|
24
|
+
expect(text).toContain("✓ appium: bundled Appium found");
|
|
25
|
+
expect(text).toContain("✗ adb: adb not found\n install platform-tools");
|
|
26
|
+
expect(text).toContain("Summary: 1 pass, 0 warn, 1 fail, 0 info");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -28,6 +28,7 @@ export interface MobileInstallOptions {
|
|
|
28
28
|
* signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
|
|
29
29
|
*/
|
|
30
30
|
export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn, ensureInstallable?: typeof ensureInstallableIpa, installIos?: typeof installIosApp): Promise<Envelope<MobileInstallResult>>;
|
|
31
|
+
export declare function renderInstallResult(data: MobileInstallResult): string;
|
|
31
32
|
declare const _default: import("citty").CommandDef<{
|
|
32
33
|
readonly app: {
|
|
33
34
|
readonly type: "string";
|
|
@@ -7,7 +7,8 @@ import { execBinary } from "../../mobile-control/exec.js";
|
|
|
7
7
|
import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
|
|
8
8
|
import { installIosApp } from "../../mobile-control/ios-devicectl.js";
|
|
9
9
|
import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
|
|
10
|
-
import { formatError, formatSuccess
|
|
10
|
+
import { formatError, formatSuccess } from "../../output.js";
|
|
11
|
+
import { printMobileResult } from "./output.js";
|
|
11
12
|
import { parsePlatformArg } from "./register-core.js";
|
|
12
13
|
function inferPlatform(appPath) {
|
|
13
14
|
const ext = extname(appPath).toLowerCase();
|
|
@@ -85,6 +86,16 @@ export async function runMobileInstall(options, exec = execBinary, ensureInstall
|
|
|
85
86
|
return formatError("api_error", message, false, mobileErrorHint(message, platform));
|
|
86
87
|
}
|
|
87
88
|
}
|
|
89
|
+
export function renderInstallResult(data) {
|
|
90
|
+
const target = data.serial ?? data.udid ?? "device";
|
|
91
|
+
const lines = [`Installed ${data.app} on ${data.platform} device ${target}.`];
|
|
92
|
+
if (data.resigned) {
|
|
93
|
+
lines.push(`Re-signed for this device: ${data.originalBundleId} installed as ${data.installedBundleId}.`);
|
|
94
|
+
}
|
|
95
|
+
if (data.output)
|
|
96
|
+
lines.push(data.output);
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
88
99
|
export default defineCommand({
|
|
89
100
|
meta: {
|
|
90
101
|
name: "install",
|
|
@@ -97,11 +108,11 @@ export default defineCommand({
|
|
|
97
108
|
udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
|
|
98
109
|
},
|
|
99
110
|
async run({ args }) {
|
|
100
|
-
|
|
111
|
+
printMobileResult(await runMobileInstall({
|
|
101
112
|
app: args.app,
|
|
102
113
|
platform: args.platform,
|
|
103
114
|
serial: args.serial,
|
|
104
115
|
udid: args.udid,
|
|
105
|
-
}));
|
|
116
|
+
}), { render: renderInstallResult });
|
|
106
117
|
},
|
|
107
118
|
});
|
|
@@ -8,5 +8,6 @@ export interface MobileListResult {
|
|
|
8
8
|
warnings?: string[];
|
|
9
9
|
}
|
|
10
10
|
export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
|
|
11
|
+
export declare function renderMobileList(data: MobileListResult): string;
|
|
11
12
|
declare const _default: import("citty").CommandDef<{}>;
|
|
12
13
|
export default _default;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
-
import { formatSuccess
|
|
2
|
+
import { formatSuccess } from "../../output.js";
|
|
3
|
+
import { printMobileResult } from "./output.js";
|
|
3
4
|
import { listAndroidDevices, listIosPhysicalDevices, } from "../../mobile-control/devices.js";
|
|
4
5
|
export async function runMobileList(exec) {
|
|
5
6
|
const warnings = [];
|
|
@@ -24,13 +25,32 @@ export async function runMobileList(exec) {
|
|
|
24
25
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
25
26
|
});
|
|
26
27
|
}
|
|
28
|
+
export function renderMobileList(data) {
|
|
29
|
+
const lines = ["Android phones (adb):"];
|
|
30
|
+
if (data.androidDevices.length === 0)
|
|
31
|
+
lines.push(" (none connected)");
|
|
32
|
+
for (const d of data.androidDevices) {
|
|
33
|
+
const label = d.model ? `${d.model} — ` : "";
|
|
34
|
+
lines.push(` - ${label}serial: ${d.serial} (${d.state})`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("iOS phones (devicectl):");
|
|
37
|
+
if (data.iosDevices.length === 0)
|
|
38
|
+
lines.push(" (none connected)");
|
|
39
|
+
for (const d of data.iosDevices) {
|
|
40
|
+
lines.push(` - ${d.name} — udid: ${d.udid}${d.state ? ` (${d.state})` : ""}`);
|
|
41
|
+
}
|
|
42
|
+
for (const w of data.warnings ?? [])
|
|
43
|
+
lines.push(`Warning: ${w}`);
|
|
44
|
+
lines.push('Next: aximo mobile register --name "My Phone" --platform android|ios');
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
27
47
|
export default defineCommand({
|
|
28
48
|
meta: {
|
|
29
49
|
name: "list",
|
|
30
|
-
description: "List physically-connected phones (Android via adb, iOS via
|
|
50
|
+
description: "List physically-connected phones (Android via adb, iOS via devicectl)",
|
|
31
51
|
},
|
|
32
52
|
args: {},
|
|
33
53
|
async run() {
|
|
34
|
-
|
|
54
|
+
printMobileResult(await runMobileList(), { render: renderMobileList });
|
|
35
55
|
},
|
|
36
56
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderMobileList } from "./list.js";
|
|
3
|
+
describe("renderMobileList", () => {
|
|
4
|
+
it("lists devices per platform with serial/udid and state", () => {
|
|
5
|
+
const text = renderMobileList({
|
|
6
|
+
androidDevices: [{ serial: "1A2B3C", state: "device", model: "Pixel_7" }],
|
|
7
|
+
iosDevices: [{ udid: "00008120-XYZ", name: "My iPhone", state: "Booted" }],
|
|
8
|
+
});
|
|
9
|
+
expect(text).toContain("Android phones (adb):");
|
|
10
|
+
expect(text).toContain(" - Pixel_7 — serial: 1A2B3C (device)");
|
|
11
|
+
expect(text).toContain("iOS phones (devicectl):");
|
|
12
|
+
expect(text).toContain(" - My iPhone — udid: 00008120-XYZ (Booted)");
|
|
13
|
+
expect(text).not.toContain("(none connected)");
|
|
14
|
+
});
|
|
15
|
+
it("marks empty platforms and surfaces discovery warnings", () => {
|
|
16
|
+
const text = renderMobileList({
|
|
17
|
+
androidDevices: [],
|
|
18
|
+
iosDevices: [],
|
|
19
|
+
warnings: ["ios (idb): idb not found"],
|
|
20
|
+
});
|
|
21
|
+
expect(text).toContain("(none connected)");
|
|
22
|
+
expect(text).toContain("Warning: ios (idb): idb not found");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
export interface PrintMobileResultOptions<T> {
|
|
3
|
+
/** Human rendering of the success payload; falls back to pretty-printed data. */
|
|
4
|
+
render?: (data: T) => string;
|
|
5
|
+
/** Defaults to stdout being a TTY; tests override. */
|
|
6
|
+
interactive?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Mobile commands are usually run by a person at a terminal, where the raw JSON
|
|
10
|
+
* envelope is hard to read. Print human-readable text when stdout is a TTY, and
|
|
11
|
+
* keep the JSON envelope when piped — scripts and agents parse it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function printMobileResult<T>(result: Envelope<T>, options?: PrintMobileResultOptions<T>): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { printResult } from "../../output.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mobile commands are usually run by a person at a terminal, where the raw JSON
|
|
4
|
+
* envelope is hard to read. Print human-readable text when stdout is a TTY, and
|
|
5
|
+
* keep the JSON envelope when piped — scripts and agents parse it.
|
|
6
|
+
*/
|
|
7
|
+
export function printMobileResult(result, options = {}) {
|
|
8
|
+
const interactive = options.interactive ?? process.stdout.isTTY === true;
|
|
9
|
+
if (!interactive) {
|
|
10
|
+
printResult(result);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
console.error(`Error: ${result.error.message}`);
|
|
15
|
+
if (result.error.hint)
|
|
16
|
+
console.error(`Hint: ${result.error.hint}`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
console.log(options.render ? options.render(result.data) : JSON.stringify(result.data, null, 2));
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { formatError, formatSuccess } from "../../output.js";
|
|
3
|
+
import { printMobileResult } from "./output.js";
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
process.exitCode = undefined;
|
|
7
|
+
});
|
|
8
|
+
describe("printMobileResult", () => {
|
|
9
|
+
it("prints the JSON envelope when not interactive", () => {
|
|
10
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
11
|
+
printMobileResult(formatSuccess({ a: 1 }), { interactive: false });
|
|
12
|
+
expect(log).toHaveBeenCalledOnce();
|
|
13
|
+
expect(JSON.parse(log.mock.calls[0]?.[0])).toEqual({ ok: true, data: { a: 1 } });
|
|
14
|
+
});
|
|
15
|
+
it("prints the JSON envelope for errors when not interactive and sets the exit code", () => {
|
|
16
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
17
|
+
printMobileResult(formatError("validation_error", "boom", false), { interactive: false });
|
|
18
|
+
expect(JSON.parse(log.mock.calls[0]?.[0])).toMatchObject({
|
|
19
|
+
ok: false,
|
|
20
|
+
error: { message: "boom" },
|
|
21
|
+
});
|
|
22
|
+
expect(process.exitCode).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
it("prints a human-readable error with hint to stderr when interactive", () => {
|
|
25
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
26
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
27
|
+
printMobileResult(formatError("validation_error", "boom", false, "try this"), {
|
|
28
|
+
interactive: true,
|
|
29
|
+
});
|
|
30
|
+
expect(log).not.toHaveBeenCalled();
|
|
31
|
+
expect(error).toHaveBeenCalledWith("Error: boom");
|
|
32
|
+
expect(error).toHaveBeenCalledWith("Hint: try this");
|
|
33
|
+
expect(process.exitCode).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
it("omits the hint line when the error has none", () => {
|
|
36
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
37
|
+
printMobileResult(formatError("api_error", "boom", true), { interactive: true });
|
|
38
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
it("renders success data with the provided renderer when interactive", () => {
|
|
41
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
42
|
+
printMobileResult(formatSuccess({ n: 2 }), {
|
|
43
|
+
interactive: true,
|
|
44
|
+
render: (d) => `n is ${d.n}`,
|
|
45
|
+
});
|
|
46
|
+
expect(log).toHaveBeenCalledWith("n is 2");
|
|
47
|
+
expect(process.exitCode).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
it("falls back to pretty-printed data without a renderer when interactive", () => {
|
|
50
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
51
|
+
printMobileResult(formatSuccess({ n: 2 }), { interactive: true });
|
|
52
|
+
expect(JSON.parse(log.mock.calls[0]?.[0])).toEqual({ n: 2 });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -10,6 +10,7 @@ export interface MobileRegisterCmdOptions {
|
|
|
10
10
|
os?: string | undefined;
|
|
11
11
|
}
|
|
12
12
|
export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
|
|
13
|
+
export declare function renderRegisterResult(data: MobileRegisterResult): string;
|
|
13
14
|
declare const _default: import("citty").CommandDef<{
|
|
14
15
|
readonly name: {
|
|
15
16
|
readonly type: "string";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import {} from "../../client.js";
|
|
3
|
-
import { formatError
|
|
3
|
+
import { formatError } from "../../output.js";
|
|
4
4
|
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
5
|
+
import { printMobileResult } from "./output.js";
|
|
5
6
|
import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
|
|
6
7
|
export async function runMobileRegister(options, deps, exec) {
|
|
7
8
|
const platform = parsePlatformArg(options.platform);
|
|
@@ -29,6 +30,13 @@ export async function runMobileRegister(options, deps, exec) {
|
|
|
29
30
|
...(options.os ? { os: options.os } : {}),
|
|
30
31
|
}, deps);
|
|
31
32
|
}
|
|
33
|
+
export function renderRegisterResult(data) {
|
|
34
|
+
return [
|
|
35
|
+
`Registered "${data.name}" (${data.platform}, target ${data.target}) as device ${data.deviceId}.`,
|
|
36
|
+
`Config: ${data.configPath}`,
|
|
37
|
+
"Next: aximo mobile connect",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
32
40
|
export default defineCommand({
|
|
33
41
|
meta: {
|
|
34
42
|
name: "register",
|
|
@@ -46,12 +54,12 @@ export default defineCommand({
|
|
|
46
54
|
os: { type: "string", description: "Optional OS version label" },
|
|
47
55
|
},
|
|
48
56
|
async run({ args }) {
|
|
49
|
-
|
|
57
|
+
printMobileResult(await runMobileRegister({
|
|
50
58
|
name: args.name,
|
|
51
59
|
platform: args.platform,
|
|
52
60
|
serial: args.serial,
|
|
53
61
|
udid: args.udid,
|
|
54
62
|
os: args.os,
|
|
55
|
-
}));
|
|
63
|
+
}), { render: renderRegisterResult });
|
|
56
64
|
},
|
|
57
65
|
});
|
|
@@ -18,6 +18,7 @@ export interface RunMobileSetupOptions {
|
|
|
18
18
|
}) => Promise<Envelope<MobileDoctorReport>>;
|
|
19
19
|
}
|
|
20
20
|
export declare function runMobileSetup(options?: RunMobileSetupOptions): Promise<Envelope<SetupResult>>;
|
|
21
|
+
export declare function renderSetupResult(data: SetupResult): string;
|
|
21
22
|
declare const _default: import("citty").CommandDef<{
|
|
22
23
|
readonly platform: {
|
|
23
24
|
readonly type: "string";
|
|
@@ -3,7 +3,8 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { formatError, formatSuccess
|
|
6
|
+
import { formatError, formatSuccess } from "../../output.js";
|
|
7
|
+
import { printMobileResult } from "./output.js";
|
|
7
8
|
import { getAppiumHome } from "../../mobile-control/appium-server.js";
|
|
8
9
|
import { runMobileDoctor } from "./doctor.js";
|
|
9
10
|
const DRIVER_PACKAGE = {
|
|
@@ -82,6 +83,10 @@ export async function runMobileSetup(options = {}) {
|
|
|
82
83
|
printNextSteps(report.ok ? report.data.checks : []);
|
|
83
84
|
return formatSuccess({ appiumHome, drivers: results });
|
|
84
85
|
}
|
|
86
|
+
export function renderSetupResult(data) {
|
|
87
|
+
const lines = data.drivers.map((d) => ` - ${d.driver}: ${d.alreadyInstalled ? "already installed" : "installed"}`);
|
|
88
|
+
return [`Appium home: ${data.appiumHome}`, ...lines].join("\n");
|
|
89
|
+
}
|
|
85
90
|
export default defineCommand({
|
|
86
91
|
meta: {
|
|
87
92
|
name: "setup",
|
|
@@ -95,6 +100,6 @@ export default defineCommand({
|
|
|
95
100
|
if (args.platform === "ios" || args.platform === "android")
|
|
96
101
|
options.platform = args.platform;
|
|
97
102
|
const result = await runMobileSetup(options);
|
|
98
|
-
|
|
103
|
+
printMobileResult(result, { render: renderSetupResult });
|
|
99
104
|
},
|
|
100
105
|
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { type ClientDeps } from "../../client.js";
|
|
2
2
|
import type { Envelope } from "../../types.js";
|
|
3
|
+
export interface MobileUnregisterResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
deviceId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
3
8
|
export declare function runMobileUnregister(options: {
|
|
4
9
|
deviceId?: string;
|
|
5
|
-
}, deps?: ClientDeps): Promise<Envelope<
|
|
6
|
-
|
|
7
|
-
}>>;
|
|
10
|
+
}, deps?: ClientDeps): Promise<Envelope<MobileUnregisterResult>>;
|
|
11
|
+
export declare function renderUnregisterResult(data: MobileUnregisterResult): string;
|
|
8
12
|
declare const _default: import("citty").CommandDef<{
|
|
9
13
|
readonly "device-id": {
|
|
10
14
|
readonly type: "string";
|
|
@@ -2,14 +2,15 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { existsSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { request } from "../../client.js";
|
|
4
4
|
import { handleApiError } from "../../errors.js";
|
|
5
|
-
import { formatSuccess
|
|
5
|
+
import { formatSuccess } from "../../output.js";
|
|
6
6
|
import { resolveDeviceConfig } from "./connect-core.js";
|
|
7
|
+
import { printMobileResult } from "./output.js";
|
|
7
8
|
import { getMobileDeviceConfigPath } from "./paths.js";
|
|
8
9
|
export async function runMobileUnregister(options, deps) {
|
|
9
10
|
const resolved = resolveDeviceConfig(options.deviceId);
|
|
10
11
|
if (!resolved.ok)
|
|
11
12
|
return resolved;
|
|
12
|
-
const { deviceId } = resolved.config;
|
|
13
|
+
const { deviceId, name } = resolved.config;
|
|
13
14
|
try {
|
|
14
15
|
await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
|
|
15
16
|
}
|
|
@@ -25,7 +26,10 @@ export async function runMobileUnregister(options, deps) {
|
|
|
25
26
|
if (existsSync(configPath)) {
|
|
26
27
|
unlinkSync(configPath);
|
|
27
28
|
}
|
|
28
|
-
return formatSuccess({ success: true });
|
|
29
|
+
return formatSuccess({ success: true, deviceId, name });
|
|
30
|
+
}
|
|
31
|
+
export function renderUnregisterResult(data) {
|
|
32
|
+
return `Unregistered "${data.name}" (device ${data.deviceId}).`;
|
|
29
33
|
}
|
|
30
34
|
export default defineCommand({
|
|
31
35
|
meta: { name: "unregister", description: "Unregister a local mobile device" },
|
|
@@ -37,6 +41,8 @@ export default defineCommand({
|
|
|
37
41
|
},
|
|
38
42
|
async run({ args }) {
|
|
39
43
|
const deviceId = args["device-id"];
|
|
40
|
-
|
|
44
|
+
printMobileResult(await runMobileUnregister(deviceId ? { deviceId } : {}), {
|
|
45
|
+
render: renderUnregisterResult,
|
|
46
|
+
});
|
|
41
47
|
},
|
|
42
48
|
});
|
|
@@ -2,5 +2,9 @@ export interface ExecResult {
|
|
|
2
2
|
stdout: Buffer;
|
|
3
3
|
stderr: string;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export interface ExecOptions {
|
|
6
|
+
/** Working directory for the child (e.g. to zip relative paths). */
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
export type ExecFn = (file: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
|
|
6
10
|
export declare const execBinary: ExecFn;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
// Explicit argv, no shell — payloads can't inject into the local machine.
|
|
3
|
-
export const execBinary = (file, args) => new Promise((resolve, reject) => {
|
|
4
|
-
execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
3
|
+
export const execBinary = (file, args, opts) => new Promise((resolve, reject) => {
|
|
4
|
+
execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }, (error, stdout, stderr) => {
|
|
5
5
|
const stderrText = stderr.toString("utf-8");
|
|
6
6
|
if (error) {
|
|
7
7
|
const message = stderrText.trim() || error.message;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
|
|
7
7
|
*/
|
|
8
8
|
export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
|
|
9
|
+
/** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
|
|
10
|
+
export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
|
|
9
11
|
/**
|
|
10
12
|
* True when the tunnel registry server answers its HTTP API on `port`. A raw
|
|
11
13
|
* TCP connect is not enough — an unrelated listener on the port would be
|
|
@@ -20,8 +22,9 @@ export interface TunnelScriptOptions {
|
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* A self-contained shell script that launches the driver's `tunnel-creation` as a
|
|
23
|
-
* detached
|
|
24
|
-
* immediately while the tunnel keeps running
|
|
25
|
+
* fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
|
|
26
|
+
* the macOS auth prompt; it returns immediately while the tunnel keeps running
|
|
27
|
+
* (health-checked via the registry port).
|
|
25
28
|
*/
|
|
26
29
|
export declare function buildTunnelScript(opts: TunnelScriptOptions): string;
|
|
27
30
|
/** Prompt for admin rights (password/Touch ID) and run the tunnel script as root. */
|
|
@@ -13,6 +13,8 @@ import { envFlag } from "./env-flags.js";
|
|
|
13
13
|
* Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
|
|
14
14
|
*/
|
|
15
15
|
export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
|
|
16
|
+
/** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
|
|
17
|
+
export const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
|
|
16
18
|
/**
|
|
17
19
|
* True when the tunnel registry server answers its HTTP API on `port`. A raw
|
|
18
20
|
* TCP connect is not enough — an unrelated listener on the port would be
|
|
@@ -36,17 +38,34 @@ export async function isTunnelHealthy(port = DEFAULT_TUNNEL_REGISTRY_PORT, host
|
|
|
36
38
|
function shQuote(value) {
|
|
37
39
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
38
40
|
}
|
|
41
|
+
// Daemonize via perl (always present on macOS). A plain `nohup … &` does NOT
|
|
42
|
+
// survive `do shell script … with administrator privileges`: the security
|
|
43
|
+
// trampoline tears down its child's process group when the script returns, and
|
|
44
|
+
// nohup only blocks SIGHUP — so the tunnel is killed before it initializes.
|
|
45
|
+
// Double-fork + setsid moves the daemon into its own session and process group,
|
|
46
|
+
// out of the trampoline's reach. $ARGV[0] is the log file; $ARGV[1..] the argv.
|
|
47
|
+
const DAEMONIZE_PERL = [
|
|
48
|
+
"use POSIX qw(setsid);",
|
|
49
|
+
"open(STDIN, '<', '/dev/null');",
|
|
50
|
+
"open(STDOUT, '>', $ARGV[0]) or die $!;",
|
|
51
|
+
"open(STDERR, '>&', STDOUT);",
|
|
52
|
+
"exit if fork;", // top process (the trampoline's child) returns immediately
|
|
53
|
+
"setsid();", // child leads a new session — detached from the trampoline
|
|
54
|
+
"exit if fork;", // grandchild can never reacquire a controlling terminal
|
|
55
|
+
"exec(@ARGV[1 .. $#ARGV]) or die $!;",
|
|
56
|
+
].join(" ");
|
|
39
57
|
/**
|
|
40
58
|
* A self-contained shell script that launches the driver's `tunnel-creation` as a
|
|
41
|
-
* detached
|
|
42
|
-
* immediately while the tunnel keeps running
|
|
59
|
+
* fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
|
|
60
|
+
* the macOS auth prompt; it returns immediately while the tunnel keeps running
|
|
61
|
+
* (health-checked via the registry port).
|
|
43
62
|
*/
|
|
44
63
|
export function buildTunnelScript(opts) {
|
|
45
64
|
return [
|
|
46
65
|
"#!/bin/sh",
|
|
47
66
|
`export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
|
|
48
67
|
'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
|
|
49
|
-
`
|
|
68
|
+
`exec perl -e ${shQuote(DAEMONIZE_PERL)} ${shQuote(opts.logFile)} ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation`,
|
|
50
69
|
"",
|
|
51
70
|
].join("\n");
|
|
52
71
|
}
|
|
@@ -81,7 +100,7 @@ async function pollHealthy(isHealthy, port, timeoutMs, logFile) {
|
|
|
81
100
|
}
|
|
82
101
|
throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
|
|
83
102
|
`Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
|
|
84
|
-
|
|
103
|
+
`be running; stop it before retrying: ${TUNNEL_STOP_COMMAND}`);
|
|
85
104
|
}
|
|
86
105
|
/**
|
|
87
106
|
* Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
|
|
@@ -125,6 +144,7 @@ export async function ensureIosTunnel(options = {}) {
|
|
|
125
144
|
`${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
|
|
126
145
|
}
|
|
127
146
|
await pollHealthy(isHealthy, port, options.readyTimeoutMs ?? 45_000, logFile);
|
|
128
|
-
log(`iOS device tunnel is up (registry on port ${port})
|
|
147
|
+
log(`iOS device tunnel is up (registry on port ${port}). It keeps running in the background ` +
|
|
148
|
+
`so later connects don't prompt again; stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
|
|
129
149
|
return { reused: false, started: true, skipped: false, port };
|
|
130
150
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel } from "./ios-tunnel.js";
|
|
3
3
|
describe("buildTunnelScript", () => {
|
|
4
|
-
it("
|
|
4
|
+
it("launches tunnel-creation as a setsid-detached daemon with APPIUM_HOME set", () => {
|
|
5
5
|
const script = buildTunnelScript({
|
|
6
6
|
appiumHome: "/home/u/.aximo/appium-home",
|
|
7
7
|
nodePath: "/usr/bin/node",
|
|
@@ -9,8 +9,13 @@ describe("buildTunnelScript", () => {
|
|
|
9
9
|
logFile: "/home/u/.aximo/ios-tunnel/tunnel.log",
|
|
10
10
|
});
|
|
11
11
|
expect(script).toContain("export APPIUM_HOME='/home/u/.aximo/appium-home'");
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Detached via perl double-fork + setsid so the admin-elevation trampoline
|
|
13
|
+
// can't reap it (a plain `nohup … &` was killed on script exit).
|
|
14
|
+
expect(script).toContain("setsid()");
|
|
15
|
+
expect(script).toContain("'/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
|
|
16
|
+
// Log file is passed as the perl daemon's first argv (STDOUT/STDERR sink).
|
|
17
|
+
expect(script).toContain("'/home/u/.aximo/ios-tunnel/tunnel.log' '/usr/bin/node'");
|
|
18
|
+
expect(script).not.toContain("nohup");
|
|
14
19
|
});
|
|
15
20
|
it("escapes single quotes in paths", () => {
|
|
16
21
|
const script = buildTunnelScript({
|
|
@@ -68,8 +68,11 @@ export async function preinstallWda(options) {
|
|
|
68
68
|
}
|
|
69
69
|
const scratch = mkdtempSync(join(tmpdir(), "aximo-wda-preinstall-"));
|
|
70
70
|
try {
|
|
71
|
-
// Wrap the bare .app into Payload/ so it
|
|
72
|
-
//
|
|
71
|
+
// Wrap the bare .app into Payload/ so it goes through the same resign path as
|
|
72
|
+
// any other IPA. Extract with ditto (it reads the Apple-produced zip cleanly)
|
|
73
|
+
// but REPACKAGE with `zip`: applesign's own extractor can't parse ditto's
|
|
74
|
+
// PKZip variant and rejects a ditto-built IPA as "Invalid IPA" (no .app found
|
|
75
|
+
// in Payload). `zip -qr Payload` from the wrap dir keeps Payload/ at the root.
|
|
73
76
|
const payloadRoot = join(scratch, "wrap");
|
|
74
77
|
const extractDir = join(payloadRoot, "Payload");
|
|
75
78
|
mkdirSync(extractDir, { recursive: true });
|
|
@@ -78,7 +81,7 @@ export async function preinstallWda(options) {
|
|
|
78
81
|
throw new Error(`Prebuilt WDA zip did not contain ${RUNNER_APP} — re-download or report.`);
|
|
79
82
|
}
|
|
80
83
|
const wrappedIpa = join(scratch, "wda.ipa");
|
|
81
|
-
await exec("
|
|
84
|
+
await exec("zip", ["-qr", wrappedIpa, "Payload"], { cwd: payloadRoot });
|
|
82
85
|
const runnerBundleId = `${options.wdaBundleId}.xctrunner`;
|
|
83
86
|
const resignedIpa = join(scratch, "wda-resigned.ipa");
|
|
84
87
|
log(`Signing WebDriverAgent as ${runnerBundleId}…`);
|
|
@@ -33,15 +33,19 @@ describe("preinstallWda", () => {
|
|
|
33
33
|
afterEach(() => {
|
|
34
34
|
rmSync(cacheDir, { recursive: true, force: true });
|
|
35
35
|
});
|
|
36
|
-
/**
|
|
37
|
-
const
|
|
36
|
+
/** exec fake: `ditto -x` extracts the runner app; `zip` writes the IPA archive. */
|
|
37
|
+
const execFake = async (file, args, opts) => {
|
|
38
38
|
if (file === "ditto" && args[0] === "-x") {
|
|
39
39
|
const dest = args[3];
|
|
40
40
|
if (dest)
|
|
41
41
|
mkdirSync(join(dest, "WebDriverAgentRunner-Runner.app"), { recursive: true });
|
|
42
42
|
}
|
|
43
|
-
if (file === "
|
|
44
|
-
|
|
43
|
+
if (file === "zip") {
|
|
44
|
+
// Like real zip: the relative Payload argument only resolves against opts.cwd.
|
|
45
|
+
if (!opts?.cwd || !existsSync(join(opts.cwd, "Payload"))) {
|
|
46
|
+
throw new Error("zip error: Nothing to do (Payload not found in cwd)");
|
|
47
|
+
}
|
|
48
|
+
const out = args[1];
|
|
45
49
|
if (out)
|
|
46
50
|
writeFileSync(out, "zip");
|
|
47
51
|
}
|
|
@@ -53,7 +57,7 @@ describe("preinstallWda", () => {
|
|
|
53
57
|
identityHash: "A".repeat(40),
|
|
54
58
|
profilePath: "/profiles/wda.mobileprovision",
|
|
55
59
|
wdaBundleId: "com.aximo.wda.testteam99",
|
|
56
|
-
exec:
|
|
60
|
+
exec: execFake,
|
|
57
61
|
cacheDir,
|
|
58
62
|
wdaVersion: "15.0.0",
|
|
59
63
|
fetchImpl: vi.fn(async (_input) => new Response(new ArrayBuffer(8), { status: 200 })),
|
|
@@ -76,7 +80,7 @@ describe("preinstallWda", () => {
|
|
|
76
80
|
profilePath: "/profiles/wda.mobileprovision",
|
|
77
81
|
bundleId: "com.aximo.wda.testteam99.xctrunner",
|
|
78
82
|
}));
|
|
79
|
-
expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"),
|
|
83
|
+
expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"), execFake);
|
|
80
84
|
// Second run: cache hit, no new download.
|
|
81
85
|
await preinstallWda(opts);
|
|
82
86
|
expect(opts.fetchImpl).toHaveBeenCalledOnce();
|
|
@@ -93,7 +97,7 @@ describe("preinstallWda", () => {
|
|
|
93
97
|
await expect(preinstallWda(opts)).rejects.toThrow(/aximo mobile setup/);
|
|
94
98
|
});
|
|
95
99
|
it("fails loudly when the zip has an unexpected layout", async () => {
|
|
96
|
-
const
|
|
100
|
+
const badExec = async (file, args) => {
|
|
97
101
|
if (file === "ditto" && args[0] === "-x") {
|
|
98
102
|
const dest = args[3];
|
|
99
103
|
if (dest)
|
|
@@ -101,7 +105,7 @@ describe("preinstallWda", () => {
|
|
|
101
105
|
}
|
|
102
106
|
return { stdout: Buffer.from(""), stderr: "" };
|
|
103
107
|
};
|
|
104
|
-
const opts = { ...makeOptions(), exec:
|
|
108
|
+
const opts = { ...makeOptions(), exec: badExec };
|
|
105
109
|
await expect(preinstallWda(opts)).rejects.toThrow(/did not contain/);
|
|
106
110
|
});
|
|
107
111
|
});
|
|
@@ -49,10 +49,43 @@ function defaultConnect(wsUrl, deviceToken) {
|
|
|
49
49
|
timeout: 10000,
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
+
// Mirrors the gateway's lock lease: a session quiet for longer than this has lost
|
|
53
|
+
// its server-side lock, so a new session id is a legitimate takeover, not a stray.
|
|
54
|
+
const SESSION_LEASE_MS = 25 * 60 * 1000;
|
|
52
55
|
export function registerHandlers(socket, controller, installApp) {
|
|
56
|
+
// Device-side guard against interleaved sessions: the gateway announces owner
|
|
57
|
+
// changes (session_acquired/session_released) and tags each command with its
|
|
58
|
+
// session id, so commands from a session that no longer owns the device are
|
|
59
|
+
// dropped even if a gateway instance lost its in-memory lock.
|
|
60
|
+
let activeSession = null;
|
|
61
|
+
socket.on("session_acquired", (args) => {
|
|
62
|
+
const sessionId = args?.sessionId;
|
|
63
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
64
|
+
activeSession = { id: sessionId, lastSeenAt: Date.now() };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
socket.on("session_released", (args) => {
|
|
68
|
+
// Only the owner's release clears the guard; a takeover by a new session is
|
|
69
|
+
// announced via session_acquired, so an unmatched release is ignored.
|
|
70
|
+
const sessionId = args?.sessionId;
|
|
71
|
+
if (activeSession && activeSession.id === sessionId) {
|
|
72
|
+
activeSession = null;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
53
75
|
const on = (event, handler) => {
|
|
54
76
|
socket.on(event, (rawArgs, ack) => {
|
|
55
|
-
|
|
77
|
+
// Destructure the routing-only tag off a clone so the caller's payload
|
|
78
|
+
// object is never mutated.
|
|
79
|
+
const { __sessionId: sessionId, ...args } = (rawArgs ?? {});
|
|
80
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
81
|
+
if (activeSession &&
|
|
82
|
+
activeSession.id !== sessionId &&
|
|
83
|
+
Date.now() - activeSession.lastSeenAt < SESSION_LEASE_MS) {
|
|
84
|
+
ack?.(fail(new Error("Device is busy with another session")));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
activeSession = { id: sessionId, lastSeenAt: Date.now() };
|
|
88
|
+
}
|
|
56
89
|
void handler(args)
|
|
57
90
|
.then((result) => ack?.(result))
|
|
58
91
|
.catch((err) => ack?.(fail(err)));
|
|
@@ -153,3 +153,85 @@ describe("registerHandlers", () => {
|
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
|
+
describe("registerHandlers session guard", () => {
|
|
157
|
+
const BUSY = { ok: false, error: "Device is busy with another session" };
|
|
158
|
+
function fire(socket, event, args) {
|
|
159
|
+
socket.handlers.get(event)?.(args);
|
|
160
|
+
}
|
|
161
|
+
it("strips __sessionId for the handler without mutating the incoming payload", async () => {
|
|
162
|
+
const tap = vi.fn(async () => { });
|
|
163
|
+
const socket = wire(stubController({ tap }));
|
|
164
|
+
const payload = { x: 5, y: 9, __sessionId: "s1" };
|
|
165
|
+
await expect(socket.emit("tap", payload)).resolves.toEqual({ ok: true });
|
|
166
|
+
expect(tap).toHaveBeenCalledWith(5, 9);
|
|
167
|
+
expect(payload.__sessionId).toBe("s1");
|
|
168
|
+
});
|
|
169
|
+
it("rejects a second session's command while the first is active", async () => {
|
|
170
|
+
const tap = vi.fn(async () => { });
|
|
171
|
+
const socket = wire(stubController({ tap }));
|
|
172
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
173
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
|
|
174
|
+
expect(tap).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
it("accepts a new session after session_released for the active one", async () => {
|
|
177
|
+
const tap = vi.fn(async () => { });
|
|
178
|
+
const socket = wire(stubController({ tap }));
|
|
179
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
180
|
+
fire(socket, "session_released", { sessionId: "s1" });
|
|
181
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
|
|
182
|
+
ok: true,
|
|
183
|
+
});
|
|
184
|
+
expect(tap).toHaveBeenCalledTimes(2);
|
|
185
|
+
});
|
|
186
|
+
it("ignores session_released for a session that is not active", async () => {
|
|
187
|
+
const tap = vi.fn(async () => { });
|
|
188
|
+
const socket = wire(stubController({ tap }));
|
|
189
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
190
|
+
fire(socket, "session_released", { sessionId: "other" });
|
|
191
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
|
|
192
|
+
expect(tap).toHaveBeenCalledTimes(1);
|
|
193
|
+
});
|
|
194
|
+
it("ignores session_released without a sessionId (malformed release)", async () => {
|
|
195
|
+
const tap = vi.fn(async () => { });
|
|
196
|
+
const socket = wire(stubController({ tap }));
|
|
197
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
198
|
+
fire(socket, "session_released", {});
|
|
199
|
+
fire(socket, "session_released", { sessionId: null });
|
|
200
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
|
|
201
|
+
expect(tap).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
it("switches owner on session_acquired and rejects the superseded session", async () => {
|
|
204
|
+
const tap = vi.fn(async () => { });
|
|
205
|
+
const socket = wire(stubController({ tap }));
|
|
206
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
207
|
+
fire(socket, "session_acquired", { sessionId: "s2" });
|
|
208
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s1" })).resolves.toEqual(BUSY);
|
|
209
|
+
await expect(socket.emit("tap", { x: 5, y: 6, __sessionId: "s2" })).resolves.toEqual({
|
|
210
|
+
ok: true,
|
|
211
|
+
});
|
|
212
|
+
expect(tap).toHaveBeenCalledTimes(2);
|
|
213
|
+
});
|
|
214
|
+
it("allows takeover once the active session is idle past the lease", async () => {
|
|
215
|
+
vi.useFakeTimers();
|
|
216
|
+
try {
|
|
217
|
+
const tap = vi.fn(async () => { });
|
|
218
|
+
const socket = wire(stubController({ tap }));
|
|
219
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
220
|
+
vi.advanceTimersByTime(26 * 60 * 1000);
|
|
221
|
+
await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
|
|
222
|
+
ok: true,
|
|
223
|
+
});
|
|
224
|
+
expect(tap).toHaveBeenCalledTimes(2);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
vi.useRealTimers();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it("leaves untagged commands ungated (pre-tagging gateways)", async () => {
|
|
231
|
+
const tap = vi.fn(async () => { });
|
|
232
|
+
const socket = wire(stubController({ tap }));
|
|
233
|
+
await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
|
|
234
|
+
await expect(socket.emit("tap", { x: 3, y: 4 })).resolves.toEqual({ ok: true });
|
|
235
|
+
expect(tap).toHaveBeenCalledTimes(2);
|
|
236
|
+
});
|
|
237
|
+
});
|