@autifyhq/aximo-cli 0.6.0 → 0.7.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 +79 -64
- package/dist/client.js +16 -1
- package/dist/commands/mobile/connect-core.d.ts +2 -1
- package/dist/commands/mobile/connect-core.js +104 -6
- package/dist/commands/mobile/connect.js +3 -1
- package/dist/commands/mobile/doctor.d.ts +3 -0
- package/dist/commands/mobile/doctor.js +44 -14
- package/dist/commands/mobile/doctor.test.d.ts +1 -0
- package/dist/commands/mobile/doctor.test.js +14 -0
- package/dist/commands/mobile/install.d.ts +9 -2
- package/dist/commands/mobile/install.js +43 -12
- package/dist/commands/mobile/install.test.js +44 -6
- package/dist/commands/mobile/list.d.ts +2 -0
- package/dist/commands/mobile/list.js +17 -11
- package/dist/commands/mobile/unregister.js +11 -6
- package/dist/mobile-control/app-install.d.ts +18 -5
- package/dist/mobile-control/app-install.js +33 -6
- package/dist/mobile-control/app-install.test.js +42 -7
- package/dist/mobile-control/appium-controller.d.ts +8 -0
- package/dist/mobile-control/appium-controller.js +19 -4
- package/dist/mobile-control/bundle-id-map.d.ts +9 -0
- package/dist/mobile-control/bundle-id-map.js +27 -0
- package/dist/mobile-control/devices.d.ts +6 -3
- package/dist/mobile-control/devices.js +26 -4
- package/dist/mobile-control/env-flags.d.ts +5 -0
- package/dist/mobile-control/env-flags.js +10 -0
- package/dist/mobile-control/env-flags.test.d.ts +1 -0
- package/dist/mobile-control/env-flags.test.js +21 -0
- package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
- package/dist/mobile-control/ios-auto-resign.js +108 -0
- package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-auto-resign.test.js +150 -0
- package/dist/mobile-control/ios-devicectl.d.ts +39 -0
- package/dist/mobile-control/ios-devicectl.js +87 -0
- package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
- package/dist/mobile-control/ios-devicectl.test.js +110 -0
- package/dist/mobile-control/ios-identity.d.ts +27 -0
- package/dist/mobile-control/ios-identity.js +49 -0
- package/dist/mobile-control/ios-identity.test.d.ts +1 -0
- package/dist/mobile-control/ios-identity.test.js +102 -0
- package/dist/mobile-control/ios-mint.d.ts +39 -0
- package/dist/mobile-control/ios-mint.js +78 -0
- package/dist/mobile-control/ios-mint.test.d.ts +1 -0
- package/dist/mobile-control/ios-mint.test.js +94 -0
- package/dist/mobile-control/ios-profiles.d.ts +46 -0
- package/dist/mobile-control/ios-profiles.js +106 -0
- package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
- package/dist/mobile-control/ios-profiles.test.js +154 -0
- package/dist/mobile-control/ios-resign.d.ts +37 -0
- package/dist/mobile-control/ios-resign.js +101 -0
- package/dist/mobile-control/ios-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-resign.test.js +84 -0
- package/dist/mobile-control/ios-session-error.d.ts +10 -0
- package/dist/mobile-control/ios-session-error.js +34 -0
- package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
- package/dist/mobile-control/ios-session-error.test.js +30 -0
- package/dist/mobile-control/ios-signing.d.ts +33 -5
- package/dist/mobile-control/ios-signing.js +56 -0
- package/dist/mobile-control/ios-signing.test.js +87 -0
- package/dist/mobile-control/ios-tunnel.d.ts +54 -0
- package/dist/mobile-control/ios-tunnel.js +130 -0
- package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
- package/dist/mobile-control/ios-tunnel.test.js +115 -0
- package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
- package/dist/mobile-control/ios-wda-cache.js +157 -0
- package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-cache.test.js +128 -0
- package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
- package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
- package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
- package/dist/mobile-control/ios-wda-preinstall.js +162 -0
- package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-preinstall.test.js +107 -0
- package/dist/mobile-control/mobile-errors.d.ts +12 -0
- package/dist/mobile-control/mobile-errors.js +91 -0
- package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
- package/dist/mobile-control/mobile-errors.test.js +48 -0
- package/dist/mobile-control/tunnel.js +45 -1
- package/package.json +2 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAximoDir } from "../commands/device/paths.js";
|
|
6
|
+
import { getAppiumHome } from "./appium-server.js";
|
|
7
|
+
import { envFlag } from "./env-flags.js";
|
|
8
|
+
/**
|
|
9
|
+
* iOS 17+/18+ device automation goes through Appium's XCUITest driver, which can
|
|
10
|
+
* only reach WebDriverAgent on the device through a RemoteXPC/CoreDevice tunnel.
|
|
11
|
+
* The driver ships a `tunnel-creation` script that must run as root (it creates a
|
|
12
|
+
* `utun` interface) and exposes a tunnel registry server on a fixed local port.
|
|
13
|
+
* Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
|
|
16
|
+
/**
|
|
17
|
+
* True when the tunnel registry server answers its HTTP API on `port`. A raw
|
|
18
|
+
* TCP connect is not enough — an unrelated listener on the port would be
|
|
19
|
+
* mistaken for the tunnel and surface much later as an opaque XCUITest error.
|
|
20
|
+
*/
|
|
21
|
+
export async function isTunnelHealthy(port = DEFAULT_TUNNEL_REGISTRY_PORT, host = "127.0.0.1", timeoutMs = 1500) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`http://${host}:${port}/remotexpc/tunnels`, {
|
|
24
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
return false;
|
|
28
|
+
await res.json();
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Single-quote a value for safe embedding in a POSIX shell script. */
|
|
36
|
+
function shQuote(value) {
|
|
37
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A self-contained shell script that launches the driver's `tunnel-creation` as a
|
|
41
|
+
* detached background daemon. Run as root via the macOS auth prompt; it returns
|
|
42
|
+
* immediately while the tunnel keeps running (health-checked via the registry port).
|
|
43
|
+
*/
|
|
44
|
+
export function buildTunnelScript(opts) {
|
|
45
|
+
return [
|
|
46
|
+
"#!/bin/sh",
|
|
47
|
+
`export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
|
|
48
|
+
'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
|
|
49
|
+
`nohup ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation > ${shQuote(opts.logFile)} 2>&1 &`,
|
|
50
|
+
"",
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
53
|
+
function resolveAppiumBin() {
|
|
54
|
+
return createRequire(import.meta.url).resolve("appium");
|
|
55
|
+
}
|
|
56
|
+
const osascriptElevate = (scriptPath) => new Promise((resolve, reject) => {
|
|
57
|
+
// The path goes in via argv + `quoted form of` so no caller-controlled string
|
|
58
|
+
// is ever spliced into the AppleScript source or the elevated shell command
|
|
59
|
+
// (AXIMO_HOME may contain quotes/spaces).
|
|
60
|
+
const appleScript = "on run argv\n" +
|
|
61
|
+
' do shell script "/bin/sh " & quoted form of item 1 of argv with administrator privileges\n' +
|
|
62
|
+
"end run";
|
|
63
|
+
execFile("osascript", ["-e", appleScript, scriptPath], (error, _stdout, stderr) => {
|
|
64
|
+
if (error) {
|
|
65
|
+
reject(new Error(stderr?.toString().trim() || error.message));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const defaultWriteScript = (path, content, dir) => {
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
writeFileSync(path, content, { mode: 0o700 });
|
|
74
|
+
};
|
|
75
|
+
async function pollHealthy(isHealthy, port, timeoutMs, logFile) {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
if (await isHealthy(port))
|
|
79
|
+
return;
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
|
|
83
|
+
`Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
|
|
84
|
+
"be running; stop it before retrying: sudo pkill -f tunnel-creation");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
|
|
88
|
+
* Reuses an already-running tunnel (no prompt); otherwise elevates once via the
|
|
89
|
+
* macOS auth dialog to start it. No-op when AXIMO_IOS_SKIP_TUNNEL is set.
|
|
90
|
+
*/
|
|
91
|
+
export async function ensureIosTunnel(options = {}) {
|
|
92
|
+
const env = options.env ?? process.env;
|
|
93
|
+
const port = options.port ?? DEFAULT_TUNNEL_REGISTRY_PORT;
|
|
94
|
+
const isHealthy = options.isHealthy ?? ((p) => isTunnelHealthy(p));
|
|
95
|
+
const log = options.log ?? ((m) => console.error(m));
|
|
96
|
+
if (envFlag(env, "AXIMO_IOS_SKIP_TUNNEL")) {
|
|
97
|
+
return { reused: false, started: false, skipped: true, port };
|
|
98
|
+
}
|
|
99
|
+
// A healthy tunnel is reused as-is — this is what makes the prompt appear at
|
|
100
|
+
// most once per boot rather than once per `connect`.
|
|
101
|
+
if (await isHealthy(port)) {
|
|
102
|
+
return { reused: true, started: false, skipped: false, port };
|
|
103
|
+
}
|
|
104
|
+
const appiumHome = options.appiumHome ?? getAppiumHome();
|
|
105
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
106
|
+
const appiumBin = options.appiumBin ?? resolveAppiumBin();
|
|
107
|
+
const dir = options.dir ?? join(getAximoDir(), "ios-tunnel");
|
|
108
|
+
const scriptPath = join(dir, "tunnel.sh");
|
|
109
|
+
const logFile = join(dir, "tunnel.log");
|
|
110
|
+
const writeScript = options.writeScript ?? defaultWriteScript;
|
|
111
|
+
writeScript(scriptPath, buildTunnelScript({ appiumHome, nodePath, appiumBin, logFile }), dir);
|
|
112
|
+
log("Starting the iOS device tunnel (needs admin). Approve the macOS password / Touch ID prompt…");
|
|
113
|
+
const elevate = options.elevate ?? osascriptElevate;
|
|
114
|
+
try {
|
|
115
|
+
await elevate(scriptPath);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
119
|
+
const declined = /User canceled|-128/i.test(detail);
|
|
120
|
+
const lead = declined
|
|
121
|
+
? "Admin approval was declined, so the iOS device tunnel wasn't started."
|
|
122
|
+
: `Could not start the iOS device tunnel: ${detail}.`;
|
|
123
|
+
throw new Error(`${lead} It is required for iOS 17+ to reach WebDriverAgent. Approve the macOS prompt and ` +
|
|
124
|
+
`retry, or start it yourself: sudo APPIUM_HOME=${shQuote(appiumHome)} ${shQuote(nodePath)} ` +
|
|
125
|
+
`${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
|
|
126
|
+
}
|
|
127
|
+
await pollHealthy(isHealthy, port, options.readyTimeoutMs ?? 45_000, logFile);
|
|
128
|
+
log(`iOS device tunnel is up (registry on port ${port}).`);
|
|
129
|
+
return { reused: false, started: true, skipped: false, port };
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel } from "./ios-tunnel.js";
|
|
3
|
+
describe("buildTunnelScript", () => {
|
|
4
|
+
it("backgrounds tunnel-creation as a detached daemon with APPIUM_HOME set", () => {
|
|
5
|
+
const script = buildTunnelScript({
|
|
6
|
+
appiumHome: "/home/u/.aximo/appium-home",
|
|
7
|
+
nodePath: "/usr/bin/node",
|
|
8
|
+
appiumBin: "/cli/node_modules/appium/index.js",
|
|
9
|
+
logFile: "/home/u/.aximo/ios-tunnel/tunnel.log",
|
|
10
|
+
});
|
|
11
|
+
expect(script).toContain("export APPIUM_HOME='/home/u/.aximo/appium-home'");
|
|
12
|
+
expect(script).toContain("nohup '/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
|
|
13
|
+
expect(script).toContain("> '/home/u/.aximo/ios-tunnel/tunnel.log' 2>&1 &");
|
|
14
|
+
});
|
|
15
|
+
it("escapes single quotes in paths", () => {
|
|
16
|
+
const script = buildTunnelScript({
|
|
17
|
+
appiumHome: "/home/o'brien/.aximo",
|
|
18
|
+
nodePath: "/usr/bin/node",
|
|
19
|
+
appiumBin: "/a/index.js",
|
|
20
|
+
logFile: "/l.log",
|
|
21
|
+
});
|
|
22
|
+
expect(script).toContain("export APPIUM_HOME='/home/o'\\''brien/.aximo'");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("ensureIosTunnel", () => {
|
|
26
|
+
const stubs = () => ({
|
|
27
|
+
elevate: vi.fn(async () => { }),
|
|
28
|
+
writeScript: vi.fn(),
|
|
29
|
+
log: vi.fn(),
|
|
30
|
+
});
|
|
31
|
+
it("reuses a healthy tunnel without prompting", async () => {
|
|
32
|
+
const s = stubs();
|
|
33
|
+
const result = await ensureIosTunnel({
|
|
34
|
+
isHealthy: async () => true,
|
|
35
|
+
...s,
|
|
36
|
+
env: {},
|
|
37
|
+
});
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
reused: true,
|
|
40
|
+
started: false,
|
|
41
|
+
skipped: false,
|
|
42
|
+
port: DEFAULT_TUNNEL_REGISTRY_PORT,
|
|
43
|
+
});
|
|
44
|
+
expect(s.elevate).not.toHaveBeenCalled();
|
|
45
|
+
expect(s.writeScript).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
it("is a no-op when AXIMO_IOS_SKIP_TUNNEL is set", async () => {
|
|
48
|
+
const s = stubs();
|
|
49
|
+
const isHealthy = vi.fn(async () => false);
|
|
50
|
+
const result = await ensureIosTunnel({
|
|
51
|
+
isHealthy,
|
|
52
|
+
...s,
|
|
53
|
+
env: { AXIMO_IOS_SKIP_TUNNEL: "1" },
|
|
54
|
+
});
|
|
55
|
+
expect(result.skipped).toBe(true);
|
|
56
|
+
expect(isHealthy).not.toHaveBeenCalled();
|
|
57
|
+
expect(s.elevate).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
it('does not skip for AXIMO_IOS_SKIP_TUNNEL="0"', async () => {
|
|
60
|
+
const s = stubs();
|
|
61
|
+
const result = await ensureIosTunnel({
|
|
62
|
+
isHealthy: async () => true,
|
|
63
|
+
...s,
|
|
64
|
+
env: { AXIMO_IOS_SKIP_TUNNEL: "0" },
|
|
65
|
+
});
|
|
66
|
+
expect(result.skipped).toBe(false);
|
|
67
|
+
expect(result.reused).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it("points at the tunnel log when the daemon never becomes healthy", async () => {
|
|
70
|
+
const s = stubs();
|
|
71
|
+
await expect(ensureIosTunnel({
|
|
72
|
+
isHealthy: async () => false,
|
|
73
|
+
appiumHome: "/ah",
|
|
74
|
+
nodePath: "/node",
|
|
75
|
+
appiumBin: "/appium",
|
|
76
|
+
dir: "/tun",
|
|
77
|
+
readyTimeoutMs: 1,
|
|
78
|
+
...s,
|
|
79
|
+
env: {},
|
|
80
|
+
})).rejects.toThrow(/tunnel\.log.*pkill -f tunnel-creation/s);
|
|
81
|
+
});
|
|
82
|
+
it("elevates and starts the tunnel when none is running", async () => {
|
|
83
|
+
const s = stubs();
|
|
84
|
+
const isHealthy = vi
|
|
85
|
+
.fn()
|
|
86
|
+
.mockResolvedValueOnce(false) // pre-check
|
|
87
|
+
.mockResolvedValue(true); // poll after elevate
|
|
88
|
+
const result = await ensureIosTunnel({
|
|
89
|
+
isHealthy,
|
|
90
|
+
appiumHome: "/ah",
|
|
91
|
+
nodePath: "/node",
|
|
92
|
+
appiumBin: "/appium",
|
|
93
|
+
dir: "/tun",
|
|
94
|
+
readyTimeoutMs: 2000,
|
|
95
|
+
...s,
|
|
96
|
+
env: {},
|
|
97
|
+
});
|
|
98
|
+
expect(result.started).toBe(true);
|
|
99
|
+
expect(s.writeScript).toHaveBeenCalledOnce();
|
|
100
|
+
expect(s.elevate).toHaveBeenCalledWith("/tun/tunnel.sh");
|
|
101
|
+
});
|
|
102
|
+
it("throws actionable guidance when the admin prompt is declined", async () => {
|
|
103
|
+
const s = stubs();
|
|
104
|
+
s.elevate.mockRejectedValueOnce(new Error("User canceled."));
|
|
105
|
+
await expect(ensureIosTunnel({
|
|
106
|
+
isHealthy: async () => false,
|
|
107
|
+
appiumHome: "/ah",
|
|
108
|
+
nodePath: "/node",
|
|
109
|
+
appiumBin: "/appium",
|
|
110
|
+
dir: "/tun",
|
|
111
|
+
...s,
|
|
112
|
+
env: {},
|
|
113
|
+
})).rejects.toThrow(/Admin approval was declined.*driver run xcuitest tunnel-creation/s);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
import type { IosSigningConfig } from "./ios-signing.js";
|
|
3
|
+
/** Managed DerivedData cache: $AXIMO_HOME/ios-wda/<udid>/derived-data. */
|
|
4
|
+
export declare function getWdaDerivedDataPath(udid?: string): string;
|
|
5
|
+
/** Locate the built WDA runner's embedded provisioning profile, if present. */
|
|
6
|
+
export declare function findEmbeddedProvisioning(derivedDataPath: string): string | null;
|
|
7
|
+
/** Extract `ExpirationDate` from a mobileprovision (decoded via `security cms`). */
|
|
8
|
+
export declare function readProvisioningExpiry(profilePath: string, exec?: ExecFn): Promise<Date | null>;
|
|
9
|
+
/** Everything that, when changed, makes a cached WDA build unusable or unsafe to reuse. */
|
|
10
|
+
export interface WdaCacheKey {
|
|
11
|
+
driverVersion: string | null;
|
|
12
|
+
xcodeVersion: string | null;
|
|
13
|
+
teamId: string | null;
|
|
14
|
+
wdaBundleId: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface ResolveWdaCachingOptions {
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
exec?: ExecFn;
|
|
19
|
+
/** Device UDID; keys the managed cache directory per device. */
|
|
20
|
+
udid?: string;
|
|
21
|
+
/** Override the default managed DerivedData directory. */
|
|
22
|
+
cacheDir?: string;
|
|
23
|
+
/** Locate the embedded profile (injectable for tests). */
|
|
24
|
+
findProfile?: (derivedDataPath: string) => string | null;
|
|
25
|
+
/** Read the installed XCUITest driver version (injectable for tests). */
|
|
26
|
+
readDriverVersion?: () => string | null;
|
|
27
|
+
now?: number;
|
|
28
|
+
minRemainingMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Enrich a signing config with WDA-cache settings: point `derivedDataPath` at the
|
|
32
|
+
* managed per-device cache and enable `usePrebuiltWDA` only when a cached build
|
|
33
|
+
* exists that (a) was built with the same driver/Xcode/signing config and (b) has
|
|
34
|
+
* a provisioning profile that is still comfortably valid. A mismatched cache is
|
|
35
|
+
* deleted so the driver does a clean rebuild. Set AXIMO_IOS_NO_WDA_CACHE to
|
|
36
|
+
* disable caching entirely. A user-supplied AXIMO_IOS_DERIVED_DATA_PATH is
|
|
37
|
+
* treated as user-managed: reused on profile validity alone, never wiped.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveWdaCaching(signing: IosSigningConfig, options?: ResolveWdaCachingOptions): Promise<IosSigningConfig>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAximoDir } from "../commands/device/paths.js";
|
|
4
|
+
import { getAppiumHome } from "./appium-server.js";
|
|
5
|
+
import { envFlag } from "./env-flags.js";
|
|
6
|
+
import { execBinary } from "./exec.js";
|
|
7
|
+
/**
|
|
8
|
+
* WebDriverAgent is expensive to rebuild+code-sign every session. Reusing a
|
|
9
|
+
* persistent DerivedData directory lets Appium's XCUITest driver skip the build
|
|
10
|
+
* (`usePrebuiltWDA`) — but only while the cached build still matches the
|
|
11
|
+
* toolchain and signing config it was built with, and its provisioning profile
|
|
12
|
+
* is still valid. A prebuilt WDA that no longer matches hard-fails the session
|
|
13
|
+
* (the driver does not fall back to rebuilding), so staleness is detected via a
|
|
14
|
+
* cache key (driver/Xcode versions, team id, WDA bundle id) and the cache is
|
|
15
|
+
* wiped for a clean rebuild on any mismatch. The directory is per-device UDID:
|
|
16
|
+
* free-team profiles only embed the UDIDs known at build time, and concurrent
|
|
17
|
+
* first sessions on two phones must not race one shared xcodebuild output dir.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_MIN_REMAINING_MS = 24 * 60 * 60 * 1000; // rebuild when <1 day of validity is left
|
|
20
|
+
const CACHE_KEY_FILE = "aximo-cache-key.json";
|
|
21
|
+
/** Managed DerivedData cache: $AXIMO_HOME/ios-wda/<udid>/derived-data. */
|
|
22
|
+
export function getWdaDerivedDataPath(udid) {
|
|
23
|
+
const root = join(getAximoDir(), "ios-wda");
|
|
24
|
+
if (!udid)
|
|
25
|
+
return join(root, "derived-data");
|
|
26
|
+
const safe = udid.replace(/[^A-Za-z0-9-]/g, "_");
|
|
27
|
+
return join(root, safe, "derived-data");
|
|
28
|
+
}
|
|
29
|
+
/** Locate the built WDA runner's embedded provisioning profile, if present. */
|
|
30
|
+
export function findEmbeddedProvisioning(derivedDataPath) {
|
|
31
|
+
const productsDir = join(derivedDataPath, "Build", "Products", "Debug-iphoneos");
|
|
32
|
+
if (!existsSync(productsDir))
|
|
33
|
+
return null;
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(productsDir);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.endsWith(".app"))
|
|
43
|
+
continue;
|
|
44
|
+
const profile = join(productsDir, entry, "embedded.mobileprovision");
|
|
45
|
+
if (existsSync(profile))
|
|
46
|
+
return profile;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** Extract `ExpirationDate` from a mobileprovision (decoded via `security cms`). */
|
|
51
|
+
export async function readProvisioningExpiry(profilePath, exec = execBinary) {
|
|
52
|
+
let xml;
|
|
53
|
+
try {
|
|
54
|
+
({ xml } = await exec("security", ["cms", "-D", "-i", profilePath]).then((r) => ({
|
|
55
|
+
xml: r.stdout.toString("utf-8"),
|
|
56
|
+
})));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const match = xml.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/);
|
|
62
|
+
if (!match?.[1])
|
|
63
|
+
return null;
|
|
64
|
+
const date = new Date(match[1]);
|
|
65
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
66
|
+
}
|
|
67
|
+
function readInstalledDriverVersion() {
|
|
68
|
+
try {
|
|
69
|
+
const pkgPath = join(getAppiumHome(), "node_modules", "appium-xcuitest-driver", "package.json");
|
|
70
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
71
|
+
return pkg.version ?? null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function readXcodeVersion(exec) {
|
|
78
|
+
try {
|
|
79
|
+
const result = await exec("xcodebuild", ["-version"]);
|
|
80
|
+
const firstLine = result.stdout.toString("utf-8").split("\n")[0]?.trim();
|
|
81
|
+
return firstLine || null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function readStoredCacheKey(derivedDataPath) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(join(derivedDataPath, CACHE_KEY_FILE), "utf-8"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function writeCacheKey(derivedDataPath, key) {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(derivedDataPath, { recursive: true });
|
|
98
|
+
writeFileSync(join(derivedDataPath, CACHE_KEY_FILE), `${JSON.stringify(key, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Best-effort: a missing key file just forces a rebuild next session.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function keysEqual(a, b) {
|
|
105
|
+
return (a.driverVersion === b.driverVersion &&
|
|
106
|
+
a.xcodeVersion === b.xcodeVersion &&
|
|
107
|
+
a.teamId === b.teamId &&
|
|
108
|
+
a.wdaBundleId === b.wdaBundleId);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Enrich a signing config with WDA-cache settings: point `derivedDataPath` at the
|
|
112
|
+
* managed per-device cache and enable `usePrebuiltWDA` only when a cached build
|
|
113
|
+
* exists that (a) was built with the same driver/Xcode/signing config and (b) has
|
|
114
|
+
* a provisioning profile that is still comfortably valid. A mismatched cache is
|
|
115
|
+
* deleted so the driver does a clean rebuild. Set AXIMO_IOS_NO_WDA_CACHE to
|
|
116
|
+
* disable caching entirely. A user-supplied AXIMO_IOS_DERIVED_DATA_PATH is
|
|
117
|
+
* treated as user-managed: reused on profile validity alone, never wiped.
|
|
118
|
+
*/
|
|
119
|
+
export async function resolveWdaCaching(signing, options = {}) {
|
|
120
|
+
const env = options.env ?? process.env;
|
|
121
|
+
if (envFlag(env, "AXIMO_IOS_NO_WDA_CACHE"))
|
|
122
|
+
return signing;
|
|
123
|
+
const exec = options.exec ?? execBinary;
|
|
124
|
+
const findProfile = options.findProfile ?? findEmbeddedProvisioning;
|
|
125
|
+
const userManaged = signing.derivedDataPath !== undefined;
|
|
126
|
+
const derivedDataPath = signing.derivedDataPath ?? options.cacheDir ?? getWdaDerivedDataPath(options.udid);
|
|
127
|
+
const enriched = { ...signing, derivedDataPath };
|
|
128
|
+
if (!userManaged) {
|
|
129
|
+
const currentKey = {
|
|
130
|
+
driverVersion: (options.readDriverVersion ?? readInstalledDriverVersion)(),
|
|
131
|
+
xcodeVersion: await readXcodeVersion(exec),
|
|
132
|
+
teamId: signing.teamId ?? null,
|
|
133
|
+
wdaBundleId: signing.wdaBundleId ?? null,
|
|
134
|
+
};
|
|
135
|
+
const storedKey = readStoredCacheKey(derivedDataPath);
|
|
136
|
+
const hasCachedBuild = findProfile(derivedDataPath) !== null;
|
|
137
|
+
if (hasCachedBuild && (!storedKey || !keysEqual(storedKey, currentKey))) {
|
|
138
|
+
// Toolchain or signing config changed since this WDA was built: a prebuilt
|
|
139
|
+
// reuse would hard-fail (or silently mismatch the driver), so start clean
|
|
140
|
+
// and let this session rebuild into the emptied cache.
|
|
141
|
+
rmSync(derivedDataPath, { recursive: true, force: true });
|
|
142
|
+
writeCacheKey(derivedDataPath, currentKey);
|
|
143
|
+
return enriched;
|
|
144
|
+
}
|
|
145
|
+
writeCacheKey(derivedDataPath, currentKey);
|
|
146
|
+
}
|
|
147
|
+
const profilePath = findProfile(derivedDataPath);
|
|
148
|
+
if (!profilePath)
|
|
149
|
+
return enriched; // no cached build yet → build (and populate the cache)
|
|
150
|
+
const expiry = await readProvisioningExpiry(profilePath, exec);
|
|
151
|
+
const now = options.now ?? Date.now();
|
|
152
|
+
const minRemainingMs = options.minRemainingMs ?? DEFAULT_MIN_REMAINING_MS;
|
|
153
|
+
if (expiry && expiry.getTime() - now > minRemainingMs) {
|
|
154
|
+
enriched.usePrebuiltWDA = true; // valid cache → reuse, skip rebuild+sign
|
|
155
|
+
}
|
|
156
|
+
return enriched;
|
|
157
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { readProvisioningExpiry, resolveWdaCaching } from "./ios-wda-cache.js";
|
|
6
|
+
const BASE = {
|
|
7
|
+
signingId: "Apple Development",
|
|
8
|
+
wdaBundleId: "com.example.WebDriverAgentRunner",
|
|
9
|
+
teamId: "TEAM123456",
|
|
10
|
+
};
|
|
11
|
+
function execReturning(byBinary) {
|
|
12
|
+
return vi.fn(async (binary) => ({
|
|
13
|
+
stdout: Buffer.from(byBinary[binary] ?? "", "utf-8"),
|
|
14
|
+
stderr: "",
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
const profileXml = (expiration) => `<plist><dict><key>ExpirationDate</key><date>${expiration}</date></dict></plist>`;
|
|
18
|
+
describe("readProvisioningExpiry", () => {
|
|
19
|
+
it("parses ExpirationDate from a decoded profile", async () => {
|
|
20
|
+
const exec = execReturning({ security: profileXml("2030-01-02T03:04:05Z") });
|
|
21
|
+
const date = await readProvisioningExpiry("/p.mobileprovision", exec);
|
|
22
|
+
expect(date?.toISOString()).toBe("2030-01-02T03:04:05.000Z");
|
|
23
|
+
});
|
|
24
|
+
it("returns null when security fails", async () => {
|
|
25
|
+
const exec = vi.fn(async () => {
|
|
26
|
+
throw new Error("security: no such file");
|
|
27
|
+
});
|
|
28
|
+
expect(await readProvisioningExpiry("/missing", exec)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("resolveWdaCaching", () => {
|
|
32
|
+
const tempDirs = [];
|
|
33
|
+
const makeCacheDir = () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "aximo-wda-cache-"));
|
|
35
|
+
tempDirs.push(dir);
|
|
36
|
+
return dir;
|
|
37
|
+
};
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
for (const dir of tempDirs.splice(0)) {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const validExec = execReturning({
|
|
44
|
+
security: profileXml("2030-01-01T00:00:00Z"),
|
|
45
|
+
xcodebuild: "Xcode 16.2\nBuild version 16C5032a",
|
|
46
|
+
});
|
|
47
|
+
const NOW = Date.parse("2026-01-01T00:00:00Z");
|
|
48
|
+
const opts = (cacheDir, overrides = {}) => ({
|
|
49
|
+
env: {},
|
|
50
|
+
cacheDir,
|
|
51
|
+
exec: validExec,
|
|
52
|
+
readDriverVersion: () => "9.0.0",
|
|
53
|
+
now: NOW,
|
|
54
|
+
...overrides,
|
|
55
|
+
});
|
|
56
|
+
it("disables caching entirely when AXIMO_IOS_NO_WDA_CACHE is set", async () => {
|
|
57
|
+
const result = await resolveWdaCaching(BASE, { env: { AXIMO_IOS_NO_WDA_CACHE: "1" } });
|
|
58
|
+
expect(result).toEqual(BASE);
|
|
59
|
+
expect(result.derivedDataPath).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
it('does NOT disable caching for AXIMO_IOS_NO_WDA_CACHE="0"', async () => {
|
|
62
|
+
const dir = makeCacheDir();
|
|
63
|
+
const result = await resolveWdaCaching(BASE, opts(dir, { env: { AXIMO_IOS_NO_WDA_CACHE: "0" }, findProfile: () => null }));
|
|
64
|
+
expect(result.derivedDataPath).toBe(dir);
|
|
65
|
+
});
|
|
66
|
+
it("defaults derivedDataPath but omits usePrebuiltWDA when no build is cached", async () => {
|
|
67
|
+
const dir = makeCacheDir();
|
|
68
|
+
const result = await resolveWdaCaching(BASE, opts(dir, { findProfile: () => null }));
|
|
69
|
+
expect(result.derivedDataPath).toBe(dir);
|
|
70
|
+
expect(result.usePrebuiltWDA).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
it("reuses a validly-signed cached build only once its cache key is recorded", async () => {
|
|
73
|
+
const dir = makeCacheDir();
|
|
74
|
+
const withProfile = opts(dir, { findProfile: () => join(dir, "embedded.mobileprovision") });
|
|
75
|
+
// First resolve: cached build predates key tracking → wiped, rebuilt, key written.
|
|
76
|
+
const first = await resolveWdaCaching(BASE, withProfile);
|
|
77
|
+
expect(first.usePrebuiltWDA).toBeUndefined();
|
|
78
|
+
// Second resolve: stored key matches → prebuilt reuse.
|
|
79
|
+
const second = await resolveWdaCaching(BASE, withProfile);
|
|
80
|
+
expect(second.usePrebuiltWDA).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
it("wipes the cache and rebuilds when the toolchain/signing key changes", async () => {
|
|
83
|
+
const dir = makeCacheDir();
|
|
84
|
+
const withProfile = (driverVersion) => opts(dir, {
|
|
85
|
+
findProfile: () => join(dir, "embedded.mobileprovision"),
|
|
86
|
+
readDriverVersion: () => driverVersion,
|
|
87
|
+
});
|
|
88
|
+
await resolveWdaCaching(BASE, withProfile("9.0.0"));
|
|
89
|
+
expect((await resolveWdaCaching(BASE, withProfile("9.0.0"))).usePrebuiltWDA).toBe(true);
|
|
90
|
+
const marker = join(dir, "Build");
|
|
91
|
+
writeFileSync(marker, "stale build output");
|
|
92
|
+
const afterUpgrade = await resolveWdaCaching(BASE, withProfile("10.0.0"));
|
|
93
|
+
expect(afterUpgrade.usePrebuiltWDA).toBeUndefined();
|
|
94
|
+
expect(existsSync(marker)).toBe(false); // stale build dir was wiped
|
|
95
|
+
// And the new key sticks: next resolve on the new driver version reuses again.
|
|
96
|
+
expect((await resolveWdaCaching(BASE, withProfile("10.0.0"))).usePrebuiltWDA).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it("rebuilds (no usePrebuiltWDA) when the cached profile is expired", async () => {
|
|
99
|
+
const dir = makeCacheDir();
|
|
100
|
+
const withProfile = opts(dir, {
|
|
101
|
+
findProfile: () => join(dir, "embedded.mobileprovision"),
|
|
102
|
+
exec: execReturning({
|
|
103
|
+
security: profileXml("2026-01-01T00:00:00Z"),
|
|
104
|
+
xcodebuild: "Xcode 16.2",
|
|
105
|
+
}),
|
|
106
|
+
now: Date.parse("2026-01-05T00:00:00Z"), // profile already expired
|
|
107
|
+
});
|
|
108
|
+
await resolveWdaCaching(BASE, withProfile); // record the key
|
|
109
|
+
const result = await resolveWdaCaching(BASE, withProfile);
|
|
110
|
+
expect(result.usePrebuiltWDA).toBeUndefined();
|
|
111
|
+
expect(result.derivedDataPath).toBe(dir);
|
|
112
|
+
});
|
|
113
|
+
it("treats an explicit derivedDataPath as user-managed: reused, never wiped or keyed", async () => {
|
|
114
|
+
const dir = makeCacheDir();
|
|
115
|
+
const marker = join(dir, "user-file");
|
|
116
|
+
writeFileSync(marker, "keep me");
|
|
117
|
+
const result = await resolveWdaCaching({ ...BASE, derivedDataPath: dir }, {
|
|
118
|
+
env: {},
|
|
119
|
+
exec: validExec,
|
|
120
|
+
now: NOW,
|
|
121
|
+
findProfile: () => join(dir, "embedded.mobileprovision"),
|
|
122
|
+
});
|
|
123
|
+
expect(result.derivedDataPath).toBe(dir);
|
|
124
|
+
expect(result.usePrebuiltWDA).toBe(true); // validity-only reuse, no key required
|
|
125
|
+
expect(existsSync(marker)).toBe(true);
|
|
126
|
+
expect(existsSync(join(dir, "aximo-cache-key.json"))).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
export interface ReadWdaFailureReasonOptions {
|
|
3
|
+
/** Managed DerivedData dir (checked before Xcode's default WebDriverAgent-* dirs). */
|
|
4
|
+
derivedDataPath?: string;
|
|
5
|
+
exec?: ExecFn;
|
|
6
|
+
/** Injectable for tests: locate the newest xcresult across candidate dirs. */
|
|
7
|
+
findXcresult?: (derivedDataPath?: string) => string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Extract the concise failure reason from the newest WDA `.xcresult`, e.g.
|
|
11
|
+
* "The application could not be launched because the Developer App Certificate
|
|
12
|
+
* is not trusted." Returns null when nothing usable is found (best-effort).
|
|
13
|
+
*/
|
|
14
|
+
export declare function readWdaFailureReason(options?: ReadWdaFailureReasonOptions): Promise<string | null>;
|