@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
|
@@ -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
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -109,6 +109,55 @@ export interface PlanRun {
|
|
|
109
109
|
status?: string | undefined;
|
|
110
110
|
[key: string]: unknown;
|
|
111
111
|
}
|
|
112
|
+
export interface PlanListItem {
|
|
113
|
+
id: string;
|
|
114
|
+
projectId: string;
|
|
115
|
+
title: string;
|
|
116
|
+
description: string | null;
|
|
117
|
+
apiEnabled: boolean;
|
|
118
|
+
caseCount: number;
|
|
119
|
+
hasMobileCases: boolean;
|
|
120
|
+
createdAt: string;
|
|
121
|
+
updatedAt: string;
|
|
122
|
+
createdBy: {
|
|
123
|
+
id: string;
|
|
124
|
+
name: string | null;
|
|
125
|
+
email: string;
|
|
126
|
+
} | null;
|
|
127
|
+
}
|
|
128
|
+
export interface PlanDetail {
|
|
129
|
+
id: string;
|
|
130
|
+
projectId: string;
|
|
131
|
+
title: string;
|
|
132
|
+
description: string | null;
|
|
133
|
+
apiEnabled: boolean;
|
|
134
|
+
executionMode: ExecutionMode;
|
|
135
|
+
stopOnFailure: boolean;
|
|
136
|
+
variables: {
|
|
137
|
+
name: string;
|
|
138
|
+
value: string;
|
|
139
|
+
isSecret: boolean;
|
|
140
|
+
}[];
|
|
141
|
+
caseCount: number;
|
|
142
|
+
createdAt: string;
|
|
143
|
+
updatedAt: string;
|
|
144
|
+
createdBy: {
|
|
145
|
+
id: string;
|
|
146
|
+
name: string | null;
|
|
147
|
+
email: string;
|
|
148
|
+
} | null;
|
|
149
|
+
project: {
|
|
150
|
+
id: string;
|
|
151
|
+
name: string;
|
|
152
|
+
} | null;
|
|
153
|
+
cases: {
|
|
154
|
+
id: string;
|
|
155
|
+
order: number;
|
|
156
|
+
type: string;
|
|
157
|
+
title: string | null;
|
|
158
|
+
lastStatus: string | null;
|
|
159
|
+
}[];
|
|
160
|
+
}
|
|
112
161
|
export interface PlanRunSummary {
|
|
113
162
|
id: string;
|
|
114
163
|
status: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autifyhq/aximo-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Autify Aximo Command Line Interface (CLI)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"autify",
|
|
@@ -52,11 +52,13 @@
|
|
|
52
52
|
"@types/node": "^25.6.0",
|
|
53
53
|
"@types/ws": "^8.18.0",
|
|
54
54
|
"@vitest/coverage-v8": "4.1.4",
|
|
55
|
+
"esbuild": "0.27.7",
|
|
55
56
|
"husky": "9.1.7",
|
|
56
57
|
"msw": "2.13.2",
|
|
57
58
|
"nano-staged": "1.0.2",
|
|
58
59
|
"oxfmt": "0.45.0",
|
|
59
60
|
"oxlint": "1.60.0",
|
|
61
|
+
"postject": "1.0.0-alpha.6",
|
|
60
62
|
"secretlint": "11.7.1",
|
|
61
63
|
"tsx": "4.21.0",
|
|
62
64
|
"typescript": "6.0.2",
|
|
@@ -67,6 +69,7 @@
|
|
|
67
69
|
},
|
|
68
70
|
"scripts": {
|
|
69
71
|
"build": "tsc",
|
|
72
|
+
"build:sea": "node scripts/build-sea.mjs",
|
|
70
73
|
"cli": "tsx src/cli.ts",
|
|
71
74
|
"format": "oxfmt --write .",
|
|
72
75
|
"format:check": "oxfmt --check .",
|