@ishlabs/cli 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/commands/doctor.d.ts +42 -0
  2. package/dist/commands/doctor.js +359 -0
  3. package/dist/commands/iteration.js +23 -5
  4. package/dist/commands/study-participant.js +1 -1
  5. package/dist/commands/study-run.js +26 -1
  6. package/dist/commands/study-screenshots.js +38 -5
  7. package/dist/index.js +2 -0
  8. package/dist/lib/api-client.d.ts +3 -0
  9. package/dist/lib/api-client.js +6 -1
  10. package/dist/lib/docs.js +15 -3
  11. package/dist/lib/local-sim/actions.d.ts +18 -0
  12. package/dist/lib/local-sim/actions.js +32 -0
  13. package/dist/lib/local-sim/adb.d.ts +33 -0
  14. package/dist/lib/local-sim/adb.js +121 -17
  15. package/dist/lib/local-sim/android.d.ts +7 -1
  16. package/dist/lib/local-sim/android.js +21 -1
  17. package/dist/lib/local-sim/coordinates.d.ts +4 -4
  18. package/dist/lib/local-sim/coordinates.js +4 -4
  19. package/dist/lib/local-sim/device.d.ts +21 -2
  20. package/dist/lib/local-sim/device.js +1 -1
  21. package/dist/lib/local-sim/ios.d.ts +33 -10
  22. package/dist/lib/local-sim/ios.js +88 -20
  23. package/dist/lib/local-sim/loop.js +134 -25
  24. package/dist/lib/local-sim/native-a11y.d.ts +21 -7
  25. package/dist/lib/local-sim/native-a11y.js +82 -47
  26. package/dist/lib/local-sim/simctl.d.ts +28 -43
  27. package/dist/lib/local-sim/simctl.js +53 -142
  28. package/dist/lib/local-sim/types.d.ts +13 -2
  29. package/dist/lib/local-sim/xcuitest.d.ts +60 -0
  30. package/dist/lib/local-sim/xcuitest.js +303 -0
  31. package/dist/lib/paths.d.ts +14 -0
  32. package/dist/lib/paths.js +21 -0
  33. package/dist/lib/report-readiness.d.ts +44 -0
  34. package/dist/lib/report-readiness.js +74 -0
  35. package/dist/lib/skill-content.js +2 -0
  36. package/package.json +1 -1
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * COORDINATE SPACE — carried, not converted, by this module:
14
14
  * - Android `uiautomator dump` bounds are screencap PIXELS (`space: "px"`).
15
- * - iOS `idb ui describe-all` frames are POINTS (`space: "points"`).
15
+ * - iOS WebDriverAgent /source frames are POINTS (`space: "points"`).
16
16
  * The device de-normalizes/taps in its own space (AndroidDevice taps pixels;
17
17
  * IOSDevice taps points), so the `space` tag tells the caller which dimension a
18
18
  * node's bounds-center belongs to. This module never mixes the two.
@@ -50,7 +50,7 @@ const ROLE_NORMALIZATION = {
50
50
  ScrollView: "generic",
51
51
  RecyclerView: "list",
52
52
  ListView: "list",
53
- // iOS (idb `type`, AX-prefixed `role` handled by stripAxPrefix below).
53
+ // iOS (WDA / XCUITest `type`, AX-prefixed `role` handled by stripAxPrefix below).
54
54
  StaticText: "text",
55
55
  TextField: "textbox",
56
56
  SecureTextField: "textbox",
@@ -181,6 +181,9 @@ function buildAndroidTree(xml) {
181
181
  function makeRawAndroidNode(role, text, contentDesc, resourceId, clickable, bounds) {
182
182
  return { role, text, contentDesc, resourceId, clickable, bounds, children: [] };
183
183
  }
184
+ // ---------------------------------------------------------------------------
185
+ // iOS — shared helpers for the WebDriverAgent (XCUITest) /source parser below
186
+ // ---------------------------------------------------------------------------
184
187
  /** iOS roles/types that are directly actionable (the device taps their center). */
185
188
  const IOS_ACTIONABLE_TYPES = new Set([
186
189
  "Button",
@@ -195,50 +198,7 @@ const IOS_ACTIONABLE_TYPES = new Set([
195
198
  "MenuItem",
196
199
  "Tab",
197
200
  ]);
198
- /**
199
- * Parse `idb ui describe-all` JSON (a FLAT array of elements, each with a `frame`
200
- * in POINTS) into NativeNodes in array order. iOS is already a flat,
201
- * properly-labeled list — no ancestor walk needed — so `clickable` is derived
202
- * from the element's role/type and whether it carries a usable label.
203
- */
204
- export function parseIdbDescribeAll(json) {
205
- let parsed;
206
- try {
207
- parsed = JSON.parse(json);
208
- }
209
- catch {
210
- return [];
211
- }
212
- if (!Array.isArray(parsed))
213
- return [];
214
- const out = [];
215
- for (const raw of parsed) {
216
- const bounds = idbFrameToBounds(raw.frame);
217
- if (!bounds)
218
- continue; // malformed / zero-area frame → no tappable center
219
- // Label: prefer the spoken AXLabel; fall back to AXValue (search fields
220
- // expose their placeholder as AXValue, e.g. "Search"). AXValue is only a
221
- // STRING fallback — switches/sliders/steppers report it as a number/boolean
222
- // (a Switch is 1/0), and `.trim()` on those would throw and lose the whole
223
- // tree to a silent vision fallback. An unlabeled toggle then emits as a bare
224
- // `[id] switch` (still tappable via its frame center).
225
- const label = (raw.AXLabel ?? (typeof raw.AXValue === "string" ? raw.AXValue : "")).trim();
226
- const rawType = raw.type ?? (raw.role ? stripAxPrefix(raw.role) : "");
227
- const typeKey = stripAxPrefix(rawType);
228
- const actionable = IOS_ACTIONABLE_TYPES.has(typeKey) && raw.enabled !== false;
229
- out.push({
230
- role: normalizeRole(rawType),
231
- label,
232
- bounds,
233
- clickable: actionable,
234
- hasOwnLabel: label.length > 0,
235
- resourceId: raw.AXUniqueId ?? undefined,
236
- space: "points",
237
- });
238
- }
239
- return out;
240
- }
241
- function idbFrameToBounds(frame) {
201
+ function frameToBounds(frame) {
242
202
  if (!frame)
243
203
  return null;
244
204
  const { x, y, width, height } = frame;
@@ -254,6 +214,81 @@ function idbFrameToBounds(frame) {
254
214
  }
255
215
  return { x, y, width, height };
256
216
  }
217
+ /** WDA's "1"/"0" (or real boolean) → boolean. */
218
+ function wdaTruthy(v) {
219
+ return v === true || v === "1";
220
+ }
221
+ /**
222
+ * Parse WDA's `GET /source?format=json` — a NESTED accessibility tree — into the
223
+ * FLAT, depth-first `NativeNode[]` (POINTS) that `parseXcuiHierarchy` produces,
224
+ * so `serializeNativeTree` consumes it unchanged. WDA's `type` matches idb's iOS
225
+ * types (Button/StaticText/SearchField/Cell/Image/Application…), so
226
+ * `normalizeRole`/`IOS_ACTIONABLE_TYPES`/`frameToBounds` all apply as-is.
227
+ *
228
+ * KEY: WDA's `/source` is the FULL XCUIElement tree — every container and leaf —
229
+ * NOT idb's clean accessibility-elements list. iOS settings rows surface as an
230
+ * accessible `Button` ("General", isAccessible=1) that ALSO contains a duplicate
231
+ * inner `StaticText` ("General", isAccessible=0) and is wrapped in a `Cell`
232
+ * (isAccessible=0). Emitting all three yields "General General" + empty
233
+ * listitems. So we emit ONLY `isAccessible && isVisible` nodes — exactly the
234
+ * VoiceOver-exposed set idb returned: the labeled Button is both the label and
235
+ * the tap target; the duplicate StaticText and the wrapping Cell are pruned. A
236
+ * sparse a11y tree degrades to the loop's vision fallback, so strict filtering
237
+ * never strands the run.
238
+ *
239
+ * Accepts either the raw tree or the W3C `{ value: <tree> }` envelope WDA returns.
240
+ */
241
+ export function parseXcuiHierarchy(json) {
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(json);
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ // WDA returns the tree under a W3C `{ value: <tree>, sessionId }` envelope, but
250
+ // a raw tree NODE also has its own `value` field (the element's value) — so we
251
+ // can't unwrap on `"value" in parsed` alone. The actual tree root is the one
252
+ // carrying a node-shaped `type`; only unwrap `value` when the top level is NOT
253
+ // itself a node.
254
+ const obj = parsed;
255
+ const root = obj && typeof obj === "object" && !("type" in obj) && "value" in obj
256
+ ? obj.value
257
+ : obj;
258
+ if (!root || typeof root !== "object")
259
+ return [];
260
+ const out = [];
261
+ const visit = (n) => {
262
+ const bounds = frameToBounds(n.rect ?? undefined);
263
+ if (bounds && wdaTruthy(n.isAccessible) && wdaTruthy(n.isVisible)) {
264
+ // Prefer the spoken label; fall back to a STRING value (search fields
265
+ // expose their placeholder as `value`). Non-string values (a Switch's 1/0)
266
+ // are ignored for the label, exactly like the idb path.
267
+ const label = (n.label ?? (typeof n.value === "string" ? n.value : "")).trim();
268
+ const rawType = n.type ?? "";
269
+ const typeKey = stripAxPrefix(rawType);
270
+ // `isEnabled` absent ⇒ assume enabled (WDA omits it on always-enabled types).
271
+ const enabled = n.isEnabled == null ? true : wdaTruthy(n.isEnabled);
272
+ const actionable = IOS_ACTIONABLE_TYPES.has(typeKey) && enabled;
273
+ out.push({
274
+ role: normalizeRole(rawType),
275
+ label,
276
+ bounds,
277
+ clickable: actionable,
278
+ hasOwnLabel: label.length > 0,
279
+ resourceId: (n.name || n.rawIdentifier) ?? undefined,
280
+ space: "points",
281
+ });
282
+ }
283
+ // Recurse into ALL children — an accessible element can nest inside a
284
+ // non-accessible container (the Cell wrapping the Button), so we must not
285
+ // prune the walk by accessibility, only the emission.
286
+ for (const c of n.children ?? [])
287
+ visit(c);
288
+ };
289
+ visit(root);
290
+ return out;
291
+ }
257
292
  // ---------------------------------------------------------------------------
258
293
  // Serialization — flat NativeNode list → `[id] role "label"` + nodeMap
259
294
  // ---------------------------------------------------------------------------
@@ -271,7 +306,7 @@ function normalizeLabel(label) {
271
306
  }
272
307
  /**
273
308
  * Serialize a flat NativeNode list (from `parseUiautomatorXml` /
274
- * `parseIdbDescribeAll`) into the `[id] role "label"` string the DOMLocator
309
+ * `parseXcuiHierarchy`) into the `[id] role "label"` string the DOMLocator
275
310
  * reasons over, plus a `shortId → bounds` map for local tap resolution.
276
311
  *
277
312
  * Emission rules (kept tight, like the DOM serializer):
@@ -1,38 +1,34 @@
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
  export declare class IosError extends Error {
21
18
  constructor(message: string);
22
19
  }
23
20
  /** Run `xcrun simctl <args>` and return trimmed stdout. */
24
21
  export declare function simctl(args: string[], timeoutMs?: number): Promise<string>;
25
- /** Run `idb <args>` and return trimmed stdout. */
26
- export declare function idb(args: string[], timeoutMs?: number): Promise<string>;
27
22
  /**
28
23
  * Assert exactly one simulator is Booted and return its udid. We pin every
29
- * subsequent idb/simctl call (and the screenshot) to "booted", so multiple
24
+ * subsequent simctl/WDA call (and the screenshot) to "booted", so multiple
30
25
  * booted simulators are ambiguous and rejected.
31
26
  */
32
27
  export declare function requireOneBootedSimulator(): Promise<string>;
33
28
  /**
34
- * Screen geometry from `idb describe --json`: PIXEL size, POINT size, and the
35
- * scale (`density`) between them. Points drive idb ui tap/swipe; pixels are the
29
+ * Screen geometry: PIXEL size, POINT size, and the scale (`density`) between
30
+ * them. Produced by the XCUITest driver's `describeScreen` (xcuitest.ts) and
31
+ * consumed by IOSDevice — points drive WDA taps/swipes; pixels are the
36
32
  * screenshot's resolution.
37
33
  */
38
34
  export interface IosScreen {
@@ -42,38 +38,12 @@ export interface IosScreen {
42
38
  pointHeight: number;
43
39
  density: number;
44
40
  }
45
- export declare function describeScreen(udid: string): Promise<IosScreen>;
46
41
  /**
47
42
  * Capture the booted simulator's screen as PNG bytes via
48
43
  * `simctl io booted screenshot`. simctl writes to a file path (no reliable
49
44
  * stdout in current Xcode), so we round-trip through a temp file.
50
45
  */
51
46
  export declare function screenshotPng(): Promise<Buffer>;
52
- export declare function uiTap(udid: string, x: number, y: number): Promise<void>;
53
- export declare function uiLongPress(udid: string, x: number, y: number, durationMs?: number): Promise<void>;
54
- export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
55
- /**
56
- * Type text into the focused field. Unlike Android's `adb shell input text`,
57
- * `idb ui text` handles spaces/unicode/quotes correctly, so no helper IME is
58
- * needed.
59
- */
60
- export declare function uiText(udid: string, text: string): Promise<void>;
61
- /**
62
- * Press a hardware key by HID usage code. `idb ui key 40` is Return/Enter
63
- * (used to submit a text field).
64
- */
65
- export declare function uiKey(udid: string, keycode: number): Promise<void>;
66
- /** HID usage code for Return/Enter. */
67
- export declare const HID_KEY_RETURN = 40;
68
- /**
69
- * Capture the current accessibility tree as `idb ui describe-all` JSON (a flat
70
- * array of elements, each with a POINT frame) and return it. Mirrors the
71
- * oracle's `ios_describe`: right after a tap the tree can be mid-transition and
72
- * come back empty/partial, so we retry until we get an array with more than just
73
- * the root application node. Throws IosError if every attempt yields a trivial
74
- * tree so the caller can degrade to the vision path.
75
- */
76
- export declare function describeAll(udid: string): Promise<string>;
77
47
  export declare function terminateApp(udid: string, bundleId: string): Promise<void>;
78
48
  export declare function launchApp(udid: string, bundleId: string): Promise<void>;
79
49
  export declare function installApp(udid: string, appPath: string): Promise<void>;
@@ -83,3 +53,18 @@ export declare function isAppInstalled(udid: string, bundleId: string): Promise<
83
53
  * terminate+launch a just-installed app without diffing the app list.
84
54
  */
85
55
  export declare function bundleIdFromApp(appPath: string): Promise<string | null>;
56
+ /**
57
+ * Read the installed app's marketing version + build number from the booted
58
+ * simulator. `simctl listapps` emits a (NeXTSTEP) plist of every installed
59
+ * bundle; we round-trip it through `plutil -convert json` and index by bundle
60
+ * id. JSON-not-keypath because a bundle id's dots (`com.apple.Preferences`)
61
+ * collide with plutil's `-extract` keypath separator.
62
+ *
63
+ * Best-effort: returns null on any failure (the run never depends on it). Works
64
+ * for both CLI-installed `.app`s and pre-installed system/app-store bundles —
65
+ * by call time the bundle id is already resolved.
66
+ */
67
+ export declare function appBuildFromSimulator(udid: string, bundleId: string): Promise<{
68
+ version: string | null;
69
+ build: string | null;
70
+ } | null>;
@@ -1,45 +1,27 @@
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";
22
- import { mkdtemp, readFile, rm } from "node:fs/promises";
19
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
23
20
  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
@@ -271,3 +142,43 @@ export async function bundleIdFromApp(appPath) {
271
142
  return null;
272
143
  }
273
144
  }
145
+ /**
146
+ * Read the installed app's marketing version + build number from the booted
147
+ * simulator. `simctl listapps` emits a (NeXTSTEP) plist of every installed
148
+ * bundle; we round-trip it through `plutil -convert json` and index by bundle
149
+ * id. JSON-not-keypath because a bundle id's dots (`com.apple.Preferences`)
150
+ * collide with plutil's `-extract` keypath separator.
151
+ *
152
+ * Best-effort: returns null on any failure (the run never depends on it). Works
153
+ * for both CLI-installed `.app`s and pre-installed system/app-store bundles —
154
+ * by call time the bundle id is already resolved.
155
+ */
156
+ export async function appBuildFromSimulator(udid, bundleId) {
157
+ const dir = await mkdtemp(join(tmpdir(), "ish-ios-apps-"));
158
+ const path = join(dir, "apps.plist");
159
+ try {
160
+ const { stdout } = await execFileAsync(XCRUN, ["simctl", "listapps", udid], {
161
+ timeout: 60_000,
162
+ maxBuffer: 16 * 1024 * 1024,
163
+ });
164
+ await writeFile(path, stdout);
165
+ const { stdout: json } = await execFileAsync(PLUTIL, ["-convert", "json", "-o", "-", path], {
166
+ timeout: 10_000,
167
+ maxBuffer: 16 * 1024 * 1024,
168
+ });
169
+ const apps = JSON.parse(json);
170
+ const app = apps[bundleId];
171
+ if (!app)
172
+ return null;
173
+ return {
174
+ version: app.CFBundleShortVersionString ?? null,
175
+ build: app.CFBundleVersion ?? null,
176
+ };
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ finally {
182
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
183
+ }
184
+ }
@@ -12,10 +12,12 @@ export interface LocalSimInitRequest {
12
12
  iteration_id: string;
13
13
  }
14
14
  export interface IterationDetails {
15
- url: string;
15
+ url?: string;
16
16
  platform: string;
17
17
  screen_format: "desktop" | "mobile_portrait";
18
18
  locale?: string;
19
+ /** Native (ios/android): the app target to install/launch (bundle id / package / path). */
20
+ app_artifact?: string;
19
21
  }
20
22
  export interface LocalSimInitResponse {
21
23
  participant_id: string;
@@ -114,6 +116,7 @@ export interface LocalStepAction {
114
116
  key: string | null;
115
117
  tab_id: string | null;
116
118
  orientation: string | null;
119
+ panel: "quick_settings" | "notifications" | null;
117
120
  scale: number | null;
118
121
  coordinates: {
119
122
  x: number;
@@ -157,6 +160,7 @@ export interface LocalSimStepResponseRaw {
157
160
  key?: string;
158
161
  tab_id?: string;
159
162
  orientation?: string;
163
+ panel?: "quick_settings" | "notifications";
160
164
  scale?: number;
161
165
  coordinates?: {
162
166
  x: number;
@@ -219,6 +223,14 @@ export interface RecordInteraction {
219
223
  assignment_status: AssignmentStatus;
220
224
  tabs?: LocalTabInfo[];
221
225
  }
226
+ export interface LocalSimInteractionRequest {
227
+ participant_id: string;
228
+ product_id: string;
229
+ interaction: RecordInteraction;
230
+ }
231
+ export interface LocalSimInteractionResponse {
232
+ interaction_id: string;
233
+ }
222
234
  export interface AssignmentStatusUpdate {
223
235
  assignment_id: string;
224
236
  status: string;
@@ -227,7 +239,6 @@ export interface AssignmentStatusUpdate {
227
239
  export interface LocalSimRecordRequest {
228
240
  participant_id: string;
229
241
  product_id: string;
230
- interactions: RecordInteraction[];
231
242
  final_status: string;
232
243
  assignment_statuses: AssignmentStatusUpdate[];
233
244
  }
@@ -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 {};