@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.
@@ -1,21 +1,18 @@
1
1
  /**
2
- * Thin async wrappers over `xcrun simctl` + `idb` for the native-iOS sim path.
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
- * Two tools, two jobs:
5
- * - `xcrun simctl` drives the simulator LIFECYCLE (boot detection, install,
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
- * - `idb ui tap/swipe` take POINTS (e.g. 393x852) — pixels / scale.
14
- * The native sim TAPS in points (de-normalize 0-1000 against the POINT size)
15
- * but RECORDS in PIXELS: dimensions() returns the pixel size so the loop's
16
- * round-trip is exact. Recording in points would drift — the point grid (393)
17
- * is coarser than the 0-1000 normalized grid, so it double-rounds. See
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 idb/simctl call (and the screenshot) to "booted", so multiple
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. Is Xcode installed and a simulator booted? ${msg}`);
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. Boot one first (e.g. `xcrun simctl boot <udid>` or open Simulator.app).");
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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {