@ishlabs/cli 0.25.0 → 0.26.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/dist/commands/doctor.d.ts +26 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/index.js +2 -0
- package/dist/lib/local-sim/actions.js +2 -0
- package/dist/lib/local-sim/adb.d.ts +10 -0
- package/dist/lib/local-sim/adb.js +16 -2
- package/dist/lib/local-sim/android.js +6 -1
- package/dist/lib/local-sim/coordinates.d.ts +4 -4
- package/dist/lib/local-sim/coordinates.js +4 -4
- package/dist/lib/local-sim/device.d.ts +2 -2
- package/dist/lib/local-sim/device.js +1 -1
- package/dist/lib/local-sim/ios.d.ts +26 -9
- package/dist/lib/local-sim/ios.js +73 -20
- package/dist/lib/local-sim/loop.js +2 -0
- package/dist/lib/local-sim/native-a11y.d.ts +21 -7
- package/dist/lib/local-sim/native-a11y.js +82 -47
- package/dist/lib/local-sim/simctl.d.ts +13 -43
- package/dist/lib/local-sim/simctl.js +12 -141
- package/dist/lib/local-sim/types.d.ts +2 -0
- package/dist/lib/local-sim/xcuitest.d.ts +60 -0
- package/dist/lib/local-sim/xcuitest.js +303 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +12 -0
- package/package.json +1 -1
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Thin async wrappers over `xcrun simctl`
|
|
2
|
+
* Thin async wrappers over `xcrun simctl` for the native-iOS sim path: simulator
|
|
3
|
+
* LIFECYCLE (boot detection, install, terminate, launch) and the SCREENSHOT.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* terminate, launch) and the SCREENSHOT.
|
|
7
|
-
* - `idb` drives UI INPUT (tap/swipe/text/key) and reports the screen
|
|
8
|
-
* geometry (pixels, points, and the scale between them).
|
|
5
|
+
* UI interaction + the accessibility tree live in `xcuitest.ts` (WebDriverAgent),
|
|
6
|
+
* NOT here — iOS no longer depends on idb.
|
|
9
7
|
*
|
|
10
8
|
* COORDINATE SPACES (the key difference from Android, where screencap and tap
|
|
11
9
|
* share one pixel space):
|
|
12
10
|
* - `simctl io booted screenshot` writes a PNG in PIXELS (e.g. 1179x2556 @3x).
|
|
13
|
-
* -
|
|
14
|
-
* The native sim TAPS in points (de-normalize 0-1000 against the POINT size)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* IOSDevice for the full derivation.
|
|
11
|
+
* - WebDriverAgent's taps/swipes + a11y frames are POINTS (e.g. 393x852).
|
|
12
|
+
* The native sim TAPS in points (de-normalize 0-1000 against the POINT size) but
|
|
13
|
+
* RECORDS in PIXELS: dimensions() returns the pixel size so the loop's round-trip
|
|
14
|
+
* is exact. Recording in points would drift — the point grid (393) is coarser
|
|
15
|
+
* than the 0-1000 normalized grid, so it double-rounds. See IOSDevice.
|
|
19
16
|
*/
|
|
20
17
|
import { execFile } from "node:child_process";
|
|
21
18
|
import { existsSync } from "node:fs";
|
|
@@ -24,22 +21,7 @@ import { tmpdir } from "node:os";
|
|
|
24
21
|
import { join } from "node:path";
|
|
25
22
|
import { promisify } from "node:util";
|
|
26
23
|
const execFileAsync = promisify(execFile);
|
|
27
|
-
// idb installs to ~/.local/bin via pip; resolve an explicit path so we don't
|
|
28
|
-
// depend on the caller's PATH. Override with ISH_IDB.
|
|
29
|
-
function resolveIdb() {
|
|
30
|
-
const fromEnv = process.env.ISH_IDB;
|
|
31
|
-
if (fromEnv && existsSync(fromEnv))
|
|
32
|
-
return fromEnv;
|
|
33
|
-
const local = `${process.env.HOME ?? ""}/.local/bin/idb`;
|
|
34
|
-
if (existsSync(local))
|
|
35
|
-
return local;
|
|
36
|
-
const homebrew = "/opt/homebrew/bin/idb";
|
|
37
|
-
if (existsSync(homebrew))
|
|
38
|
-
return homebrew;
|
|
39
|
-
return "idb";
|
|
40
|
-
}
|
|
41
24
|
const XCRUN = "/usr/bin/xcrun";
|
|
42
|
-
const IDB = resolveIdb();
|
|
43
25
|
const PLUTIL = "/usr/bin/plutil";
|
|
44
26
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
45
27
|
const SCREENSHOT_TIMEOUT_MS = 30_000;
|
|
@@ -63,24 +45,10 @@ export async function simctl(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
63
45
|
throw new IosError(`xcrun simctl ${args.join(" ")} failed: ${msg}`);
|
|
64
46
|
}
|
|
65
47
|
}
|
|
66
|
-
/** Run `idb <args>` and return trimmed stdout. */
|
|
67
|
-
export async function idb(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
68
|
-
try {
|
|
69
|
-
const { stdout } = await execFileAsync(IDB, args, {
|
|
70
|
-
timeout: timeoutMs,
|
|
71
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
72
|
-
});
|
|
73
|
-
return stdout.trim();
|
|
74
|
-
}
|
|
75
|
-
catch (err) {
|
|
76
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
-
throw new IosError(`idb ${args.join(" ")} failed: ${msg}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
48
|
// --- Device state ---
|
|
81
49
|
/**
|
|
82
50
|
* Assert exactly one simulator is Booted and return its udid. We pin every
|
|
83
|
-
* subsequent
|
|
51
|
+
* subsequent simctl/WDA call (and the screenshot) to "booted", so multiple
|
|
84
52
|
* booted simulators are ambiguous and rejected.
|
|
85
53
|
*/
|
|
86
54
|
export async function requireOneBootedSimulator() {
|
|
@@ -90,7 +58,7 @@ export async function requireOneBootedSimulator() {
|
|
|
90
58
|
}
|
|
91
59
|
catch (err) {
|
|
92
60
|
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
-
throw new IosError(`Could not run xcrun simctl.
|
|
61
|
+
throw new IosError(`Could not run xcrun simctl. Run \`ish check ios\` to check your setup. ${msg}`);
|
|
94
62
|
}
|
|
95
63
|
let booted = [];
|
|
96
64
|
try {
|
|
@@ -104,7 +72,7 @@ export async function requireOneBootedSimulator() {
|
|
|
104
72
|
throw new IosError("Could not parse `simctl list devices booted -j` output.");
|
|
105
73
|
}
|
|
106
74
|
if (booted.length === 0) {
|
|
107
|
-
throw new IosError("No iOS simulator booted.
|
|
75
|
+
throw new IosError("No iOS simulator booted. Open Simulator.app, or run `ish check ios` to check your setup.");
|
|
108
76
|
}
|
|
109
77
|
if (booted.length > 1) {
|
|
110
78
|
throw new IosError(`Expected exactly one booted simulator, found ${booted.length} (${booted.map((d) => d.name).join(", ")}). ` +
|
|
@@ -112,27 +80,6 @@ export async function requireOneBootedSimulator() {
|
|
|
112
80
|
}
|
|
113
81
|
return booted[0].udid;
|
|
114
82
|
}
|
|
115
|
-
export async function describeScreen(udid) {
|
|
116
|
-
const out = await idb(["describe", "--json", "--udid", udid]);
|
|
117
|
-
let dims;
|
|
118
|
-
try {
|
|
119
|
-
const parsed = JSON.parse(out);
|
|
120
|
-
dims = parsed.screen_dimensions;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
throw new IosError("Could not parse `idb describe --json` output.");
|
|
124
|
-
}
|
|
125
|
-
if (!dims || !dims.width_points || !dims.height_points || !dims.width || !dims.height) {
|
|
126
|
-
throw new IosError(`idb describe returned no usable screen_dimensions: ${out.slice(0, 200)}`);
|
|
127
|
-
}
|
|
128
|
-
return {
|
|
129
|
-
pixelWidth: dims.width,
|
|
130
|
-
pixelHeight: dims.height,
|
|
131
|
-
pointWidth: dims.width_points,
|
|
132
|
-
pointHeight: dims.height_points,
|
|
133
|
-
density: dims.density ?? dims.width / dims.width_points,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
83
|
// --- Screenshot (PIXELS) ---
|
|
137
84
|
/**
|
|
138
85
|
* Capture the booted simulator's screen as PNG bytes via
|
|
@@ -150,82 +97,6 @@ export async function screenshotPng() {
|
|
|
150
97
|
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
151
98
|
}
|
|
152
99
|
}
|
|
153
|
-
// --- UI input via idb (POINTS) ---
|
|
154
|
-
export async function uiTap(udid, x, y) {
|
|
155
|
-
await idb(["ui", "tap", "--udid", udid, String(Math.round(x)), String(Math.round(y))]);
|
|
156
|
-
}
|
|
157
|
-
export async function uiLongPress(udid, x, y, durationMs = 600) {
|
|
158
|
-
// idb takes the press duration in SECONDS.
|
|
159
|
-
await idb([
|
|
160
|
-
"ui", "tap", "--udid", udid,
|
|
161
|
-
"--duration", (durationMs / 1000).toFixed(2),
|
|
162
|
-
String(Math.round(x)), String(Math.round(y)),
|
|
163
|
-
]);
|
|
164
|
-
}
|
|
165
|
-
export async function uiSwipe(udid, x1, y1, x2, y2, durationMs = 300) {
|
|
166
|
-
await idb([
|
|
167
|
-
"ui", "swipe", "--udid", udid,
|
|
168
|
-
"--duration", (durationMs / 1000).toFixed(2),
|
|
169
|
-
String(Math.round(x1)), String(Math.round(y1)),
|
|
170
|
-
String(Math.round(x2)), String(Math.round(y2)),
|
|
171
|
-
]);
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Type text into the focused field. Unlike Android's `adb shell input text`,
|
|
175
|
-
* `idb ui text` handles spaces/unicode/quotes correctly, so no helper IME is
|
|
176
|
-
* needed.
|
|
177
|
-
*/
|
|
178
|
-
export async function uiText(udid, text) {
|
|
179
|
-
await idb(["ui", "text", "--udid", udid, text]);
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Press a hardware key by HID usage code. `idb ui key 40` is Return/Enter
|
|
183
|
-
* (used to submit a text field).
|
|
184
|
-
*/
|
|
185
|
-
export async function uiKey(udid, keycode) {
|
|
186
|
-
await idb(["ui", "key", "--udid", udid, String(keycode)]);
|
|
187
|
-
}
|
|
188
|
-
/** HID usage code for Return/Enter. */
|
|
189
|
-
export const HID_KEY_RETURN = 40;
|
|
190
|
-
// --- Accessibility tree (idb describe-all) ---
|
|
191
|
-
/**
|
|
192
|
-
* Capture the current accessibility tree as `idb ui describe-all` JSON (a flat
|
|
193
|
-
* array of elements, each with a POINT frame) and return it. Mirrors the
|
|
194
|
-
* oracle's `ios_describe`: right after a tap the tree can be mid-transition and
|
|
195
|
-
* come back empty/partial, so we retry until we get an array with more than just
|
|
196
|
-
* the root application node. Throws IosError if every attempt yields a trivial
|
|
197
|
-
* tree so the caller can degrade to the vision path.
|
|
198
|
-
*/
|
|
199
|
-
export async function describeAll(udid) {
|
|
200
|
-
let lastJson = "";
|
|
201
|
-
for (let i = 0; i < 5; i++) {
|
|
202
|
-
try {
|
|
203
|
-
const json = await idb(["ui", "describe-all", "--udid", udid]);
|
|
204
|
-
lastJson = json;
|
|
205
|
-
// A valid non-trivial tree has more than just the root application node.
|
|
206
|
-
if (countJsonArray(json) >= 2)
|
|
207
|
-
return json;
|
|
208
|
-
}
|
|
209
|
-
catch (err) {
|
|
210
|
-
lastJson = err instanceof Error ? err.message : String(err);
|
|
211
|
-
}
|
|
212
|
-
await delay(800);
|
|
213
|
-
}
|
|
214
|
-
throw new IosError(`idb ui describe-all returned a trivial/empty tree after retries (last: ${lastJson.slice(0, 200)})`);
|
|
215
|
-
}
|
|
216
|
-
/** Length of a JSON array string, or 0 if it isn't a parseable array. */
|
|
217
|
-
function countJsonArray(json) {
|
|
218
|
-
try {
|
|
219
|
-
const parsed = JSON.parse(json);
|
|
220
|
-
return Array.isArray(parsed) ? parsed.length : 0;
|
|
221
|
-
}
|
|
222
|
-
catch {
|
|
223
|
-
return 0;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
function delay(ms) {
|
|
227
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
228
|
-
}
|
|
229
100
|
// --- App lifecycle (simctl) ---
|
|
230
101
|
export async function terminateApp(udid, bundleId) {
|
|
231
102
|
// Terminating an app that isn't running exits non-zero ("found nothing to
|
|
@@ -114,6 +114,7 @@ export interface LocalStepAction {
|
|
|
114
114
|
key: string | null;
|
|
115
115
|
tab_id: string | null;
|
|
116
116
|
orientation: string | null;
|
|
117
|
+
panel: "quick_settings" | "notifications" | null;
|
|
117
118
|
scale: number | null;
|
|
118
119
|
coordinates: {
|
|
119
120
|
x: number;
|
|
@@ -157,6 +158,7 @@ export interface LocalSimStepResponseRaw {
|
|
|
157
158
|
key?: string;
|
|
158
159
|
tab_id?: string;
|
|
159
160
|
orientation?: string;
|
|
161
|
+
panel?: "quick_settings" | "notifications";
|
|
160
162
|
scale?: number;
|
|
161
163
|
coordinates?: {
|
|
162
164
|
x: number;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side WebDriverAgent (XCUITest) client — the iOS UI-interaction + a11y
|
|
3
|
+
* layer that replaces idb. Device LIFECYCLE (install target app, screenshot,
|
|
4
|
+
* launch/terminate target) stays on `xcrun simctl` in `simctl.ts`; this module
|
|
5
|
+
* owns ONLY the WDA surface.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism (validated on the simulator, no xcodebuild): a PREBUILT WDA runner
|
|
8
|
+
* (Appium's `WebDriverAgentRunner-Runner.app`, bundle id
|
|
9
|
+
* `com.facebook.WebDriverAgentRunner.xctrunner`) is `simctl install`-ed and
|
|
10
|
+
* `simctl launch`-ed with `SIMCTL_CHILD_USE_PORT`; the xctrunner self-hosts the
|
|
11
|
+
* XCTest runtime and serves W3C WebDriver on `localhost:<port>`. We then drive
|
|
12
|
+
* taps/swipes/text via `/session/:id/actions` and read the a11y tree via
|
|
13
|
+
* `/session/:id/source?format=json` (parsed by `parseXcuiHierarchy`).
|
|
14
|
+
*
|
|
15
|
+
* API mirrors the idb functions in `simctl.ts` (udid-keyed, points space) so
|
|
16
|
+
* `ios.ts` swaps the import with a near-identical call surface. Per-udid session
|
|
17
|
+
* state (port + WDA sessionId) is held in a module map; `ensureWda` is idempotent
|
|
18
|
+
* and reuses an already-running runner.
|
|
19
|
+
*/
|
|
20
|
+
import { type IosScreen } from "./simctl.js";
|
|
21
|
+
interface Session {
|
|
22
|
+
port: number;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the prebuilt WDA `.app`, downloading it on first use:
|
|
28
|
+
* `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
|
|
29
|
+
* WebDriverAgent-for-simulator from its GitHub release. Mirrors
|
|
30
|
+
* `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveWdaBundle(): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
|
|
35
|
+
* runner already answers on the port (a prior `ensureWda`, or an externally
|
|
36
|
+
* launched one), it is reused — only a fresh session is created. Otherwise the
|
|
37
|
+
* prebuilt runner is installed and `simctl launch`-ed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ensureWda(udid: string, opts?: {
|
|
40
|
+
bundleId?: string;
|
|
41
|
+
}): Promise<Session>;
|
|
42
|
+
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
43
|
+
export declare function closeWda(udid: string): Promise<void>;
|
|
44
|
+
/** Screen geometry from WDA `/wda/screen` (points + retina scale). */
|
|
45
|
+
export declare function describeScreen(udid: string): Promise<IosScreen>;
|
|
46
|
+
/** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
|
|
47
|
+
export declare function describeAll(udid: string): Promise<string>;
|
|
48
|
+
export declare function uiTap(udid: string, x: number, y: number): Promise<void>;
|
|
49
|
+
export declare function uiLongPress(udid: string, x: number, y: number, durationMs?: number): Promise<void>;
|
|
50
|
+
export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
|
|
51
|
+
/** Type into the focused element (the caller taps a field first). */
|
|
52
|
+
export declare function uiText(udid: string, text: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
55
|
+
* map it to W3C ENTER. Unknown codes are a no-op-safe error.
|
|
56
|
+
*/
|
|
57
|
+
export declare function uiKey(udid: string, keycode: number): Promise<void>;
|
|
58
|
+
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
59
|
+
export declare const HID_KEY_RETURN = 40;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side WebDriverAgent (XCUITest) client — the iOS UI-interaction + a11y
|
|
3
|
+
* layer that replaces idb. Device LIFECYCLE (install target app, screenshot,
|
|
4
|
+
* launch/terminate target) stays on `xcrun simctl` in `simctl.ts`; this module
|
|
5
|
+
* owns ONLY the WDA surface.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism (validated on the simulator, no xcodebuild): a PREBUILT WDA runner
|
|
8
|
+
* (Appium's `WebDriverAgentRunner-Runner.app`, bundle id
|
|
9
|
+
* `com.facebook.WebDriverAgentRunner.xctrunner`) is `simctl install`-ed and
|
|
10
|
+
* `simctl launch`-ed with `SIMCTL_CHILD_USE_PORT`; the xctrunner self-hosts the
|
|
11
|
+
* XCTest runtime and serves W3C WebDriver on `localhost:<port>`. We then drive
|
|
12
|
+
* taps/swipes/text via `/session/:id/actions` and read the a11y tree via
|
|
13
|
+
* `/session/:id/source?format=json` (parsed by `parseXcuiHierarchy`).
|
|
14
|
+
*
|
|
15
|
+
* API mirrors the idb functions in `simctl.ts` (udid-keyed, points space) so
|
|
16
|
+
* `ios.ts` swaps the import with a near-identical call surface. Per-udid session
|
|
17
|
+
* state (port + WDA sessionId) is held in a module map; `ensureWda` is idempotent
|
|
18
|
+
* and reuses an already-running runner.
|
|
19
|
+
*/
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { wdaDir, wdaVersionFile } from "../paths.js";
|
|
25
|
+
import { IosError } from "./simctl.js";
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
const XCRUN = "/usr/bin/xcrun";
|
|
28
|
+
/** Bundle id of Appium's prebuilt WDA xctrunner (self-hosts the XCTest runtime). */
|
|
29
|
+
const WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
30
|
+
/** Default WDA port; override with ISH_WDA_PORT. One device → one runner. */
|
|
31
|
+
const DEFAULT_PORT = Number(process.env.ISH_WDA_PORT) || 8100;
|
|
32
|
+
/** WDA's XCTest runtime cold-starts slowly; poll /status up to this long. */
|
|
33
|
+
const STARTUP_TIMEOUT_MS = 75_000;
|
|
34
|
+
/** W3C ENTER key (maps the idb HID Return keycode 40 used by ios.ts). */
|
|
35
|
+
const W3C_ENTER = "\uE007"; // idb HID Return (40) -> W3C ENTER
|
|
36
|
+
const sessions = new Map();
|
|
37
|
+
// ── WDA bundle resolution (fetch is wired in the distribution phase) ──────────
|
|
38
|
+
/** Appium's prebuilt WebDriverAgent simulator release we fetch + pin. */
|
|
39
|
+
const WDA_PINNED_TAG = "v13.2.0";
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the prebuilt WDA `.app`, downloading it on first use:
|
|
42
|
+
* `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
|
|
43
|
+
* WebDriverAgent-for-simulator from its GitHub release. Mirrors
|
|
44
|
+
* `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveWdaBundle() {
|
|
47
|
+
const override = process.env.ISH_WDA_PATH;
|
|
48
|
+
if (override) {
|
|
49
|
+
if (!existsSync(override)) {
|
|
50
|
+
throw new IosError(`ISH_WDA_PATH does not exist: ${override}`);
|
|
51
|
+
}
|
|
52
|
+
return override.endsWith(".app") ? override : findApp(override);
|
|
53
|
+
}
|
|
54
|
+
const dir = wdaDir();
|
|
55
|
+
if (existsSync(dir)) {
|
|
56
|
+
try {
|
|
57
|
+
return findApp(dir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// cache dir exists but holds no .app yet → (re)download
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return downloadWdaBundle();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fetch Appium's prebuilt WDA-for-simulator zip (just the xctrunner `.app`,
|
|
67
|
+
* which self-hosts the XCTest runtime — no xcodebuild) and unpack it into
|
|
68
|
+
* `~/.ish/bin/wda/`. arch follows the host (arm64 sims on Apple Silicon, x86_64
|
|
69
|
+
* on Intel). Logs to stderr so a run shows the one-time fetch.
|
|
70
|
+
*/
|
|
71
|
+
async function downloadWdaBundle() {
|
|
72
|
+
const arch = process.arch === "arm64" ? "arm64" : "x86_64";
|
|
73
|
+
const asset = `WebDriverAgentRunner-Build-Sim-${arch}.zip`;
|
|
74
|
+
const url = `https://github.com/appium/WebDriverAgent/releases/download/${WDA_PINNED_TAG}/${asset}`;
|
|
75
|
+
const dir = wdaDir();
|
|
76
|
+
console.error(`Fetching the iOS automation runner (WebDriverAgent ${WDA_PINNED_TAG}, ${arch})...`);
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
const zipPath = path.join(dir, "wda.zip");
|
|
79
|
+
let resp;
|
|
80
|
+
try {
|
|
81
|
+
resp = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
throw new IosError(`failed to download WebDriverAgent from ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
throw new IosError(`failed to download WebDriverAgent: HTTP ${resp.status} from ${url}`);
|
|
88
|
+
}
|
|
89
|
+
writeFileSync(zipPath, Buffer.from(await resp.arrayBuffer()));
|
|
90
|
+
try {
|
|
91
|
+
await execFileAsync("/usr/bin/unzip", ["-o", "-q", zipPath, "-d", dir], { timeout: 60_000 });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
throw new IosError(`failed to unpack WebDriverAgent: ${e instanceof Error ? e.message : String(e)}`);
|
|
95
|
+
}
|
|
96
|
+
rmSync(zipPath, { force: true });
|
|
97
|
+
writeFileSync(wdaVersionFile(), `${WDA_PINNED_TAG} ${arch}\n`);
|
|
98
|
+
return findApp(dir);
|
|
99
|
+
}
|
|
100
|
+
/** First `*.app` directly under `dir` (or `dir` itself if it is one). */
|
|
101
|
+
function findApp(dir) {
|
|
102
|
+
if (dir.endsWith(".app") && existsSync(dir))
|
|
103
|
+
return dir;
|
|
104
|
+
try {
|
|
105
|
+
const hit = readdirSync(dir).find((e) => e.endsWith(".app"));
|
|
106
|
+
if (hit)
|
|
107
|
+
return path.join(dir, hit);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* fallthrough */
|
|
111
|
+
}
|
|
112
|
+
throw new IosError(`No WebDriverAgentRunner-Runner.app found under ${dir}`);
|
|
113
|
+
}
|
|
114
|
+
// ── HTTP to the runner ────────────────────────────────────────────────────
|
|
115
|
+
async function wdaCall(port, method, route, body) {
|
|
116
|
+
const url = `http://localhost:${port}${route}`;
|
|
117
|
+
let resp;
|
|
118
|
+
try {
|
|
119
|
+
resp = await fetch(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
122
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
123
|
+
signal: AbortSignal.timeout(30_000),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new IosError(`WDA ${method} ${route} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
128
|
+
}
|
|
129
|
+
const text = await resp.text();
|
|
130
|
+
let json;
|
|
131
|
+
try {
|
|
132
|
+
json = text ? JSON.parse(text) : {};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new IosError(`WDA ${method} ${route} returned non-JSON (HTTP ${resp.status})`);
|
|
136
|
+
}
|
|
137
|
+
if (!resp.ok) {
|
|
138
|
+
const detail = json?.value?.message ?? text.slice(0, 200);
|
|
139
|
+
throw new IosError(`WDA ${method} ${route} -> HTTP ${resp.status}: ${detail}`);
|
|
140
|
+
}
|
|
141
|
+
return json;
|
|
142
|
+
}
|
|
143
|
+
function unwrap(json) {
|
|
144
|
+
return json && typeof json === "object" && "value" in json
|
|
145
|
+
? json.value
|
|
146
|
+
: json;
|
|
147
|
+
}
|
|
148
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
149
|
+
async function simctlRun(args, timeoutMs = 180_000) {
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = await execFileAsync(XCRUN, ["simctl", ...args], {
|
|
152
|
+
timeout: timeoutMs,
|
|
153
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
154
|
+
});
|
|
155
|
+
return stdout;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
throw new IosError(`xcrun simctl ${args[0]} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
162
|
+
async function statusOk(port) {
|
|
163
|
+
try {
|
|
164
|
+
const resp = await fetch(`http://localhost:${port}/status`, { signal: AbortSignal.timeout(2500) });
|
|
165
|
+
return resp.ok;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
|
|
173
|
+
* runner already answers on the port (a prior `ensureWda`, or an externally
|
|
174
|
+
* launched one), it is reused — only a fresh session is created. Otherwise the
|
|
175
|
+
* prebuilt runner is installed and `simctl launch`-ed.
|
|
176
|
+
*/
|
|
177
|
+
export async function ensureWda(udid, opts = {}) {
|
|
178
|
+
const existing = sessions.get(udid);
|
|
179
|
+
if (existing && (await statusOk(existing.port)))
|
|
180
|
+
return existing;
|
|
181
|
+
const port = DEFAULT_PORT;
|
|
182
|
+
if (!(await statusOk(port))) {
|
|
183
|
+
const app = await resolveWdaBundle();
|
|
184
|
+
await simctlRun(["install", udid, app]);
|
|
185
|
+
// SIMCTL_CHILD_* env passes through to the launched process; USE_PORT tells
|
|
186
|
+
// WDA which port to bind. --terminate-running-process clears a stale runner.
|
|
187
|
+
await execFileAsync(XCRUN, ["simctl", "launch", "--terminate-running-process", udid, WDA_BUNDLE_ID], { timeout: 30_000, env: { ...process.env, SIMCTL_CHILD_USE_PORT: String(port) } }).catch((e) => {
|
|
188
|
+
throw new IosError(`failed to launch WDA runner: ${e instanceof Error ? e.message : String(e)}`);
|
|
189
|
+
});
|
|
190
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
191
|
+
while (!(await statusOk(port))) {
|
|
192
|
+
if (Date.now() > deadline) {
|
|
193
|
+
throw new IosError(`WDA runner did not start within ${STARTUP_TIMEOUT_MS / 1000}s`);
|
|
194
|
+
}
|
|
195
|
+
await sleep(1000);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const baseUrl = `http://localhost:${port}`;
|
|
199
|
+
const caps = opts.bundleId
|
|
200
|
+
? { capabilities: { alwaysMatch: { bundleId: opts.bundleId } } }
|
|
201
|
+
: { capabilities: { alwaysMatch: {} } };
|
|
202
|
+
const resp = (await wdaCall(port, "POST", "/session", caps));
|
|
203
|
+
const sessionId = resp.sessionId ?? unwrap(resp)?.sessionId;
|
|
204
|
+
if (!sessionId)
|
|
205
|
+
throw new IosError("WDA did not return a sessionId");
|
|
206
|
+
const session = { port, baseUrl, sessionId };
|
|
207
|
+
sessions.set(udid, session);
|
|
208
|
+
return session;
|
|
209
|
+
}
|
|
210
|
+
async function getSession(udid) {
|
|
211
|
+
const s = sessions.get(udid);
|
|
212
|
+
if (s && (await statusOk(s.port)))
|
|
213
|
+
return s;
|
|
214
|
+
return ensureWda(udid);
|
|
215
|
+
}
|
|
216
|
+
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
217
|
+
export async function closeWda(udid) {
|
|
218
|
+
const s = sessions.get(udid);
|
|
219
|
+
sessions.delete(udid);
|
|
220
|
+
if (!s)
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
await wdaCall(s.port, "DELETE", `/session/${s.sessionId}`);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* best-effort teardown */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ── Geometry + a11y ─────────────────────────────────────────────────────────
|
|
230
|
+
/** Screen geometry from WDA `/wda/screen` (points + retina scale). */
|
|
231
|
+
export async function describeScreen(udid) {
|
|
232
|
+
const s = await getSession(udid);
|
|
233
|
+
const v = unwrap(await wdaCall(s.port, "GET", "/wda/screen"));
|
|
234
|
+
const pointWidth = Number(v.screenSize?.width) || 0;
|
|
235
|
+
const pointHeight = Number(v.screenSize?.height) || 0;
|
|
236
|
+
const density = Number(v.scale) || 1;
|
|
237
|
+
if (!pointWidth || !pointHeight)
|
|
238
|
+
throw new IosError("WDA /wda/screen returned no screen size");
|
|
239
|
+
return {
|
|
240
|
+
pointWidth,
|
|
241
|
+
pointHeight,
|
|
242
|
+
density,
|
|
243
|
+
pixelWidth: Math.round(pointWidth * density),
|
|
244
|
+
pixelHeight: Math.round(pointHeight * density),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
|
|
248
|
+
export async function describeAll(udid) {
|
|
249
|
+
const s = await getSession(udid);
|
|
250
|
+
const json = await wdaCall(s.port, "GET", `/session/${s.sessionId}/source?format=json`);
|
|
251
|
+
return JSON.stringify(json);
|
|
252
|
+
}
|
|
253
|
+
// ── Gestures (W3C pointer actions; coordinates in POINTS) ────────────────────
|
|
254
|
+
function pointerAction(steps) {
|
|
255
|
+
return {
|
|
256
|
+
actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: steps }],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async function performActions(udid, steps) {
|
|
260
|
+
const s = await getSession(udid);
|
|
261
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/actions`, pointerAction(steps));
|
|
262
|
+
}
|
|
263
|
+
export async function uiTap(udid, x, y) {
|
|
264
|
+
await performActions(udid, [
|
|
265
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
266
|
+
{ type: "pointerDown", button: 0 },
|
|
267
|
+
{ type: "pause", duration: 60 },
|
|
268
|
+
{ type: "pointerUp", button: 0 },
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
export async function uiLongPress(udid, x, y, durationMs = 600) {
|
|
272
|
+
await performActions(udid, [
|
|
273
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
274
|
+
{ type: "pointerDown", button: 0 },
|
|
275
|
+
{ type: "pause", duration: durationMs },
|
|
276
|
+
{ type: "pointerUp", button: 0 },
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
export async function uiSwipe(udid, x1, y1, x2, y2, durationMs = 300) {
|
|
280
|
+
await performActions(udid, [
|
|
281
|
+
{ type: "pointerMove", duration: 0, x: x1, y: y1 },
|
|
282
|
+
{ type: "pointerDown", button: 0 },
|
|
283
|
+
{ type: "pointerMove", duration: durationMs, x: x2, y: y2 },
|
|
284
|
+
{ type: "pointerUp", button: 0 },
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
/** Type into the focused element (the caller taps a field first). */
|
|
288
|
+
export async function uiText(udid, text) {
|
|
289
|
+
const s = await getSession(udid);
|
|
290
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [...text] });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
294
|
+
* map it to W3C ENTER. Unknown codes are a no-op-safe error.
|
|
295
|
+
*/
|
|
296
|
+
export async function uiKey(udid, keycode) {
|
|
297
|
+
if (keycode !== 40)
|
|
298
|
+
throw new IosError(`unsupported WDA keycode: ${keycode}`);
|
|
299
|
+
const s = await getSession(udid);
|
|
300
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [W3C_ENTER] });
|
|
301
|
+
}
|
|
302
|
+
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
303
|
+
export const HID_KEY_RETURN = 40;
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -12,4 +12,12 @@ export declare function binDir(): string;
|
|
|
12
12
|
export declare function browsersDir(): string;
|
|
13
13
|
export declare function simulationsDir(): string;
|
|
14
14
|
export declare function cloudflaredBin(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Cache dir for the prebuilt iOS XCUITest runner (WebDriverAgent) bundle —
|
|
17
|
+
* the `.app` + `.xctestrun` fetched on demand for native iOS simulations,
|
|
18
|
+
* mirroring how `cloudflaredBin()` is fetched into `binDir()`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function wdaDir(): string;
|
|
21
|
+
/** Stamp file recording which CLI/runner version the cached WDA bundle is for. */
|
|
22
|
+
export declare function wdaVersionFile(): string;
|
|
15
23
|
export declare function connectLockPath(): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -34,6 +34,18 @@ export function cloudflaredBin() {
|
|
|
34
34
|
const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
35
35
|
return path.join(binDir(), exe);
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Cache dir for the prebuilt iOS XCUITest runner (WebDriverAgent) bundle —
|
|
39
|
+
* the `.app` + `.xctestrun` fetched on demand for native iOS simulations,
|
|
40
|
+
* mirroring how `cloudflaredBin()` is fetched into `binDir()`.
|
|
41
|
+
*/
|
|
42
|
+
export function wdaDir() {
|
|
43
|
+
return path.join(binDir(), "wda");
|
|
44
|
+
}
|
|
45
|
+
/** Stamp file recording which CLI/runner version the cached WDA bundle is for. */
|
|
46
|
+
export function wdaVersionFile() {
|
|
47
|
+
return path.join(wdaDir(), "VERSION");
|
|
48
|
+
}
|
|
37
49
|
export function connectLockPath() {
|
|
38
50
|
return path.join(rootDir(), "connect.lock");
|
|
39
51
|
}
|