@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.
- package/dist/commands/doctor.d.ts +42 -0
- package/dist/commands/doctor.js +359 -0
- package/dist/commands/iteration.js +23 -5
- package/dist/commands/study-participant.js +1 -1
- package/dist/commands/study-run.js +26 -1
- package/dist/commands/study-screenshots.js +38 -5
- package/dist/index.js +2 -0
- package/dist/lib/api-client.d.ts +3 -0
- package/dist/lib/api-client.js +6 -1
- package/dist/lib/docs.js +15 -3
- package/dist/lib/local-sim/actions.d.ts +18 -0
- package/dist/lib/local-sim/actions.js +32 -0
- package/dist/lib/local-sim/adb.d.ts +33 -0
- package/dist/lib/local-sim/adb.js +121 -17
- package/dist/lib/local-sim/android.d.ts +7 -1
- package/dist/lib/local-sim/android.js +21 -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 +21 -2
- package/dist/lib/local-sim/device.js +1 -1
- package/dist/lib/local-sim/ios.d.ts +33 -10
- package/dist/lib/local-sim/ios.js +88 -20
- package/dist/lib/local-sim/loop.js +134 -25
- 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 +28 -43
- package/dist/lib/local-sim/simctl.js +53 -142
- package/dist/lib/local-sim/types.d.ts +13 -2
- 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 +14 -0
- package/dist/lib/paths.js +21 -0
- package/dist/lib/report-readiness.d.ts +44 -0
- package/dist/lib/report-readiness.js +74 -0
- package/dist/lib/skill-content.js +2 -0
- 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
|
|
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 (
|
|
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
|
-
* `
|
|
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`
|
|
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
|
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
|
|
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
|
|
35
|
-
*
|
|
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`
|
|
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";
|
|
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
|
|
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
|
|
@@ -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
|
|
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 {};
|