@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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ish check / ish setup — environment diagnostics + dependency setup for
3
+ * local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
4
+ *
5
+ * `check [platform]` runs read-only checks and prints a flutter-doctor-style
6
+ * checklist (or `--json`), scoped to one platform's group + Account when a
7
+ * platform is given (ios|android|web), else everything. `setup`
8
+ * fetches/installs what it can (Chromium today; the iOS XCUITest runner once
9
+ * the artifact pipeline lands) and prints guidance for the rest, then re-runs
10
+ * the checks.
11
+ *
12
+ * Design notes:
13
+ * - Device probes are light, NON-throwing `execFile` calls reimplemented here
14
+ * (not imported from the local-sim drivers): doctor must never throw on a
15
+ * missing tool, and must not pull the playwright/local-sim module graph.
16
+ * - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
17
+ * deep-imports a non-exported playwright path that crashes at module load on
18
+ * the npm-installed CLI, so it can only be loaded lazily (same reason
19
+ * `study run --local` imports it dynamically).
20
+ * - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
21
+ * WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
22
+ * that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
23
+ * build.
24
+ */
25
+ import type { Command } from "commander";
26
+ export declare function registerDoctorCommands(program: Command): void;
@@ -0,0 +1,334 @@
1
+ /**
2
+ * ish check / ish setup — environment diagnostics + dependency setup for
3
+ * local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
4
+ *
5
+ * `check [platform]` runs read-only checks and prints a flutter-doctor-style
6
+ * checklist (or `--json`), scoped to one platform's group + Account when a
7
+ * platform is given (ios|android|web), else everything. `setup`
8
+ * fetches/installs what it can (Chromium today; the iOS XCUITest runner once
9
+ * the artifact pipeline lands) and prints guidance for the rest, then re-runs
10
+ * the checks.
11
+ *
12
+ * Design notes:
13
+ * - Device probes are light, NON-throwing `execFile` calls reimplemented here
14
+ * (not imported from the local-sim drivers): doctor must never throw on a
15
+ * missing tool, and must not pull the playwright/local-sim module graph.
16
+ * - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
17
+ * deep-imports a non-exported playwright path that crashes at module load on
18
+ * the npm-installed CLI, so it can only be loaded lazily (same reason
19
+ * `study run --local` imports it dynamically).
20
+ * - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
21
+ * WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
22
+ * that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
23
+ * build.
24
+ */
25
+ import { execFile } from "node:child_process";
26
+ import { promisify } from "node:util";
27
+ import { existsSync, readdirSync } from "node:fs";
28
+ import * as path from "node:path";
29
+ import { runInline } from "../lib/command-helpers.js";
30
+ import { output } from "../lib/output.js";
31
+ import { loadConfig } from "../config.js";
32
+ import { decodeJwtClaims, isTokenExpired } from "../auth.js";
33
+ import { wdaDir } from "../lib/paths.js";
34
+ import pkg from "../../package.json" with { type: "json" };
35
+ const execFileAsync = promisify(execFile);
36
+ const XCRUN = "/usr/bin/xcrun";
37
+ const isMac = process.platform === "darwin";
38
+ async function tryExec(cmd, args, timeoutMs = 8000) {
39
+ try {
40
+ const { stdout, stderr } = await execFileAsync(cmd, args, {
41
+ timeout: timeoutMs,
42
+ maxBuffer: 8 * 1024 * 1024,
43
+ });
44
+ return { ok: true, stdout, stderr };
45
+ }
46
+ catch (e) {
47
+ const err = e;
48
+ return {
49
+ ok: false,
50
+ stdout: err.stdout ?? "",
51
+ stderr: err.stderr ?? err.message ?? String(e),
52
+ };
53
+ }
54
+ }
55
+ /** adb path, mirroring the device layer's resolution (ISH_ADB → ANDROID_HOME → PATH). */
56
+ function resolveAdb() {
57
+ for (const v of [process.env.ISH_ADB, process.env.ADB]) {
58
+ if (v && existsSync(v))
59
+ return v;
60
+ }
61
+ for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
62
+ if (home) {
63
+ const p = path.join(home, "platform-tools", "adb");
64
+ if (existsSync(p))
65
+ return p;
66
+ }
67
+ }
68
+ return "adb";
69
+ }
70
+ // ── Individual checks ──────────────────────────────────────────────────────
71
+ function checkAuth() {
72
+ const cfg = loadConfig();
73
+ const token = cfg.access_token || cfg.token;
74
+ if (!token) {
75
+ return { key: "auth", name: "Account", group: "Account", status: "fail", message: "not logged in", fix: "ish login" };
76
+ }
77
+ const claims = decodeJwtClaims(token);
78
+ const email = claims?.email ?? "authenticated";
79
+ if (isTokenExpired(token)) {
80
+ // Expired access tokens auto-refresh via the refresh_token on the next API
81
+ // call, so this is a warn, not a hard fail.
82
+ return { key: "auth", name: "Account", group: "Account", status: "warn", message: `${email} (token expired — refreshes on next call)`, fix: "ish login (if refresh fails)" };
83
+ }
84
+ return { key: "auth", name: "Account", group: "Account", status: "pass", message: email };
85
+ }
86
+ function checkWorkspace() {
87
+ const cfg = loadConfig();
88
+ if (cfg.workspace) {
89
+ return { key: "workspace", name: "Active workspace", group: "Account", status: "pass", message: cfg.workspace };
90
+ }
91
+ return { key: "workspace", name: "Active workspace", group: "Account", status: "warn", message: "none set", fix: "ish workspace use <id>" };
92
+ }
93
+ async function checkXcode() {
94
+ if (!isMac) {
95
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "skip", message: "iOS simulations require macOS" };
96
+ }
97
+ const r = await tryExec(XCRUN, ["--version"]);
98
+ if (r.ok) {
99
+ const ver = r.stdout.trim().split("\n")[0] || "xcrun present";
100
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "pass", message: ver };
101
+ }
102
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "fail", message: "xcrun not found", fix: "Install Xcode from the App Store, then `xcodebuild -runFirstLaunch`" };
103
+ }
104
+ async function checkSimulator() {
105
+ if (!isMac) {
106
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "skip" };
107
+ }
108
+ const r = await tryExec(XCRUN, ["simctl", "list", "devices", "booted", "-j"]);
109
+ if (!r.ok) {
110
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not query simctl", fix: "Install Xcode" };
111
+ }
112
+ let booted = [];
113
+ try {
114
+ const parsed = JSON.parse(r.stdout);
115
+ booted = Object.values(parsed.devices ?? {})
116
+ .flat()
117
+ .filter((d) => d.state === "Booted")
118
+ .map((d) => ({ name: d.name, udid: d.udid }));
119
+ }
120
+ catch {
121
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
122
+ }
123
+ if (booted.length === 1) {
124
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
125
+ }
126
+ if (booted.length === 0) {
127
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: "none booted", fix: "Open Simulator.app (Xcode ships simulators) or `xcrun simctl boot <udid>`" };
128
+ }
129
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: `${booted.length} booted — native runs drive exactly one`, fix: "Shut down the extras" };
130
+ }
131
+ /** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
132
+ function wdaBundlePresent() {
133
+ const override = process.env.ISH_WDA_PATH;
134
+ if (override)
135
+ return existsSync(override);
136
+ const dir = wdaDir();
137
+ if (!existsSync(dir))
138
+ return false;
139
+ try {
140
+ return readdirSync(dir).some((e) => e.endsWith(".app"));
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ function checkXcuitestRunner() {
147
+ if (!isMac) {
148
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "skip" };
149
+ }
150
+ if (wdaBundlePresent()) {
151
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "pass", message: process.env.ISH_WDA_PATH ? "ISH_WDA_PATH" : wdaDir() };
152
+ }
153
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "warn", message: "not installed", fix: "ish setup (or set ISH_WDA_PATH to a local WebDriverAgent build)" };
154
+ }
155
+ async function checkAdb() {
156
+ const adbPath = resolveAdb();
157
+ const r = await tryExec(adbPath, ["devices"]);
158
+ if (!r.ok) {
159
+ const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "Install Android platform-tools" };
160
+ const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
161
+ return { adb, emulator };
162
+ }
163
+ const adb = { key: "adb", name: "Android adb", group: "Android", status: "pass", message: adbPath };
164
+ // Lines after the "List of devices attached" header, in "device" state.
165
+ const devices = r.stdout
166
+ .split("\n")
167
+ .slice(1)
168
+ .map((l) => l.trim())
169
+ .filter((l) => l.endsWith("\tdevice"));
170
+ const emulator = devices.length === 1
171
+ ? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
172
+ : devices.length === 0
173
+ ? { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online", fix: "Create + boot an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" }
174
+ : { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
175
+ return { adb, emulator };
176
+ }
177
+ async function checkChromium() {
178
+ try {
179
+ const { isBrowserInstalled } = await import("../lib/local-sim/install.js");
180
+ return isBrowserInstalled()
181
+ ? { key: "chromium", name: "Chromium (web local)", group: "Web", status: "pass", message: "installed" }
182
+ : { key: "chromium", name: "Chromium (web local)", group: "Web", status: "warn", message: "not installed", fix: "ish setup" };
183
+ }
184
+ catch {
185
+ return { key: "chromium", name: "Chromium (web local)", group: "Web", status: "skip", message: "unavailable in this build" };
186
+ }
187
+ }
188
+ function checkCli() {
189
+ return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
190
+ }
191
+ async function runChecks() {
192
+ const { adb, emulator } = await checkAdb();
193
+ return [
194
+ await checkXcode(),
195
+ await checkSimulator(),
196
+ checkXcuitestRunner(),
197
+ adb,
198
+ emulator,
199
+ await checkChromium(),
200
+ checkAuth(),
201
+ checkWorkspace(),
202
+ checkCli(),
203
+ ];
204
+ }
205
+ // ── Rendering ───────────────────────────────────────────────────────────────
206
+ const MARK = { pass: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", skip: "[ -- ]" };
207
+ const GROUP_ORDER = ["iOS", "Android", "Web", "Account"];
208
+ const GROUP_LABEL = {
209
+ iOS: "iOS (native simulations)",
210
+ Android: "Android (native simulations)",
211
+ Web: "Web (local browser simulations)",
212
+ Account: "Account",
213
+ };
214
+ // `ish check <platform>` scopes the report to one platform's group + Account,
215
+ // so an Android study never nags about Xcode and an iOS study never about adb.
216
+ const PLATFORM_GROUPS = {
217
+ ios: ["iOS", "Account"],
218
+ android: ["Android", "Account"],
219
+ web: ["Web", "Account"],
220
+ };
221
+ function scopeChecks(checks, platform) {
222
+ if (!platform)
223
+ return checks;
224
+ const groups = PLATFORM_GROUPS[platform];
225
+ return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
226
+ }
227
+ function overall(checks) {
228
+ if (checks.some((c) => c.status === "fail"))
229
+ return "fail";
230
+ if (checks.some((c) => c.status === "warn"))
231
+ return "warn";
232
+ return "pass";
233
+ }
234
+ function renderHuman(checks) {
235
+ const lines = ["", "ish check — local & native simulation environment", ""];
236
+ const width = Math.max(...checks.map((c) => c.name.length)) + 2;
237
+ for (const group of GROUP_ORDER) {
238
+ const inGroup = checks.filter((c) => c.group === group);
239
+ if (inGroup.length === 0)
240
+ continue;
241
+ lines.push(` ${GROUP_LABEL[group]}`);
242
+ for (const c of inGroup) {
243
+ const label = c.name.padEnd(width, " ");
244
+ let line = ` ${MARK[c.status]} ${label}${c.message ?? ""}`;
245
+ lines.push(line.trimEnd());
246
+ if ((c.status === "warn" || c.status === "fail") && c.fix) {
247
+ lines.push(` ↳ ${c.fix}`);
248
+ }
249
+ }
250
+ lines.push("");
251
+ }
252
+ const o = overall(checks);
253
+ const fails = checks.filter((c) => c.status === "fail").length;
254
+ const warns = checks.filter((c) => c.status === "warn").length;
255
+ if (o === "pass") {
256
+ lines.push("Everything checks out.");
257
+ }
258
+ else {
259
+ const parts = [fails ? `${fails} blocking` : "", warns ? `${warns} optional` : ""].filter(Boolean);
260
+ lines.push(`${parts.join(", ")} issue(s). Run \`ish setup\` to fetch what's fetchable.`);
261
+ }
262
+ console.log(lines.join("\n"));
263
+ }
264
+ // ── Commands ──────────────────────────────────────────────────────────────
265
+ export function registerDoctorCommands(program) {
266
+ program
267
+ .command("check [platform]")
268
+ .alias("doctor")
269
+ .description("Check your local simulation setup for a platform (ios | android | web), or everything")
270
+ .action(async (platform, _opts, cmd) => {
271
+ await runInline(cmd, async (globals) => {
272
+ const scope = platform?.toLowerCase();
273
+ if (scope && !PLATFORM_GROUPS[scope]) {
274
+ throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
275
+ }
276
+ const checks = scopeChecks(await runChecks(), scope);
277
+ if (globals.json) {
278
+ output({ checks, overall: overall(checks) }, true);
279
+ }
280
+ else {
281
+ renderHuman(checks);
282
+ }
283
+ });
284
+ });
285
+ program
286
+ .command("setup")
287
+ .description("Fetch/install local-simulation dependencies (Chromium, iOS runner) and show what's left to do")
288
+ .action(async (_opts, cmd) => {
289
+ await runInline(cmd, async (globals) => {
290
+ const quiet = globals.json;
291
+ const log = (m) => { if (!quiet)
292
+ console.error(m); };
293
+ // 1. Chromium (web local) — auto-fetchable.
294
+ try {
295
+ const { isBrowserInstalled, ensureBrowser } = await import("../lib/local-sim/install.js");
296
+ if (isBrowserInstalled()) {
297
+ log("Chromium: already installed.");
298
+ }
299
+ else {
300
+ log("Chromium: fetching for local web simulations...");
301
+ await ensureBrowser({ quiet, skipPrompt: true });
302
+ }
303
+ }
304
+ catch (e) {
305
+ log(`Chromium: could not install (${e instanceof Error ? e.message : String(e)}).`);
306
+ }
307
+ // 2. iOS XCUITest runner (WebDriverAgent) — auto-fetchable on macOS.
308
+ if (isMac) {
309
+ try {
310
+ if (wdaBundlePresent()) {
311
+ log("iOS runner: already installed.");
312
+ }
313
+ else {
314
+ log("iOS runner: fetching WebDriverAgent...");
315
+ const { resolveWdaBundle } = await import("../lib/local-sim/xcuitest.js");
316
+ await resolveWdaBundle();
317
+ }
318
+ }
319
+ catch (e) {
320
+ log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
321
+ }
322
+ }
323
+ // 3. Re-run checks so the user sees the resulting state, and hand back
324
+ // the structured result on --json.
325
+ const checks = await runChecks();
326
+ if (globals.json) {
327
+ output({ checks, overall: overall(checks) }, true);
328
+ }
329
+ else {
330
+ renderHuman(checks);
331
+ }
332
+ });
333
+ });
334
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerDocsCommands } from "./commands/docs.js";
19
19
  import { registerInitCommands } from "./commands/init.js";
20
20
  import { registerMcpCommands } from "./commands/mcp.js";
21
21
  import { registerSecretCommands } from "./commands/secret.js";
22
+ import { registerDoctorCommands } from "./commands/doctor.js";
22
23
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
23
24
  import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
24
25
  import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
@@ -510,6 +511,7 @@ registerDocsCommands(program);
510
511
  registerInitCommands(program);
511
512
  registerMcpCommands(program);
512
513
  registerSecretCommands(program);
514
+ registerDoctorCommands(program);
513
515
  program
514
516
  .command("upgrade")
515
517
  .description("Update ish to the latest version")
@@ -429,6 +429,8 @@ export function describeAction(action) {
429
429
  return `wait ${action.duration_ms ?? 1000}ms`;
430
430
  case "navigate_back":
431
431
  return "navigate back";
432
+ case "open_system_panel":
433
+ return `open_system_panel (${action.panel ?? "notifications"})`;
432
434
  case "long_press":
433
435
  return `long_press on '${element}'${modSuffix}`;
434
436
  case "double_tap":
@@ -44,6 +44,16 @@ export declare function inputDrag(x1: number, y1: number, x2: number, y2: number
44
44
  /** A long-press is a zero-distance swipe held for `durationMs`. */
45
45
  export declare function inputLongPress(x: number, y: number, durationMs?: number): Promise<void>;
46
46
  export declare function pressKeyEvent(keyevent: string): Promise<void>;
47
+ /**
48
+ * Open a system panel via `adb shell cmd statusbar expand-settings|expand-notifications`
49
+ * — a deterministic OS-driven open, chosen over a top-edge `input swipe` because the
50
+ * generic swipe is center-anchored and a synthetic top-edge swipe never registers the
51
+ * system pull-down. `expand-notifications` opens the notification shade;
52
+ * `expand-settings` pulls all the way to quick settings.
53
+ */
54
+ export declare function statusbarExpand(panel: "expand-settings" | "expand-notifications"): Promise<void>;
55
+ /** Collapse the open system panel (`cmd statusbar collapse`). */
56
+ export declare function statusbarCollapse(): Promise<void>;
47
57
  /**
48
58
  * Force a device orientation. We first disable auto-rotation
49
59
  * (`accelerometer_rotation 0`) — otherwise the sensor immediately overrides
@@ -90,7 +90,7 @@ export async function requireOneDevice() {
90
90
  }
91
91
  catch (err) {
92
92
  const msg = err instanceof Error ? err.message : String(err);
93
- throw new AdbError(`Could not run adb (looked for "${ADB}"). Is the Android SDK installed and an emulator booted? ${msg}`);
93
+ throw new AdbError(`Could not run adb (looked for "${ADB}"). Run \`ish check android\` to check your setup. ${msg}`);
94
94
  }
95
95
  // Output: "List of devices attached\n<serial>\tdevice\n..."
96
96
  const online = out
@@ -99,7 +99,7 @@ export async function requireOneDevice() {
99
99
  .map((l) => l.trim())
100
100
  .filter((l) => l && l.endsWith("\tdevice"));
101
101
  if (online.length === 0) {
102
- throw new AdbError("No Android device/emulator online. Boot one first (e.g. `npm run mobile-e2e-setup`).");
102
+ throw new AdbError("No Android device/emulator online. Run `ish check android` to check your setup and how to boot one.");
103
103
  }
104
104
  if (online.length > 1) {
105
105
  throw new AdbError(`Expected exactly one Android device, found ${online.length}. Stop the extras (the sim drives a single device).`);
@@ -153,6 +153,20 @@ export async function inputLongPress(x, y, durationMs = 600) {
153
153
  export async function pressKeyEvent(keyevent) {
154
154
  await adbShell(["input", "keyevent", keyevent]);
155
155
  }
156
+ /**
157
+ * Open a system panel via `adb shell cmd statusbar expand-settings|expand-notifications`
158
+ * — a deterministic OS-driven open, chosen over a top-edge `input swipe` because the
159
+ * generic swipe is center-anchored and a synthetic top-edge swipe never registers the
160
+ * system pull-down. `expand-notifications` opens the notification shade;
161
+ * `expand-settings` pulls all the way to quick settings.
162
+ */
163
+ export async function statusbarExpand(panel) {
164
+ await adbShell(["cmd", "statusbar", panel]);
165
+ }
166
+ /** Collapse the open system panel (`cmd statusbar collapse`). */
167
+ export async function statusbarCollapse() {
168
+ await adbShell(["cmd", "statusbar", "collapse"]);
169
+ }
156
170
  /**
157
171
  * Force a device orientation. We first disable auto-rotation
158
172
  * (`accelerometer_rotation 0`) — otherwise the sensor immediately overrides
@@ -19,7 +19,7 @@
19
19
  * - Vision path: px = round(x / 1000 * screencapWidth); same for y.
20
20
  */
21
21
  import { resolveTextValue } from "./actions.js";
22
- import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, ADB_KEYBOARD_PKG, } from "./adb.js";
22
+ import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, statusbarExpand, ADB_KEYBOARD_PKG, } from "./adb.js";
23
23
  import { isLocalPath } from "../upload.js";
24
24
  import { deNormalizePoint, deNormalizeDrag } from "./coordinates.js";
25
25
  import { parseUiautomatorXml, serializeNativeTree, boundsCenter } from "./native-a11y.js";
@@ -276,6 +276,11 @@ export class AndroidDevice {
276
276
  await pressKeyEvent("KEYCODE_BACK");
277
277
  break;
278
278
  }
279
+ case "open_system_panel": {
280
+ // Element-less, like navigate_back: no px, just an OS-driven panel open.
281
+ await statusbarExpand(action.panel === "quick_settings" ? "expand-settings" : "expand-notifications");
282
+ break;
283
+ }
279
284
  case "drag": {
280
285
  // A drag GRABS an element and RELEASES it elsewhere ("click the
281
286
  // element, move, let go") — distinct from a swipe (element-less
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The backend's vision locator returns NORMALIZED 0-1000 coordinates. A native
5
5
  * device de-normalizes them against a concrete dimension (screencap pixels for
6
- * Android; idb POINTS for the iOS tap, screenshot PIXELS for the iOS record),
6
+ * Android; WDA POINTS for the iOS tap, screenshot PIXELS for the iOS record),
7
7
  * and the loop later re-normalizes the recorded coordinate back to 0-1000.
8
8
  *
9
9
  * Pure and side-effect-free so the round-trip can be unit-tested without a
@@ -32,7 +32,7 @@ export declare function deNormalizePoint(c: {
32
32
  * De-normalize a drag's start AND end points (each normalized 0-1000) against
33
33
  * one device dimension into the {start,end} pixel/point pair the drivers feed to
34
34
  * a slow swipe. The drag path is a from→to gesture, so BOTH ends de-normalize
35
- * against the SAME basis (Android: screencap pixels; iOS: idb POINTS). Pure so
35
+ * against the SAME basis (Android: screencap pixels; iOS: WDA POINTS). Pure so
36
36
  * the two-ended de-normalization is unit-testable without a device.
37
37
  */
38
38
  export declare function deNormalizeDrag(drag: {
@@ -52,9 +52,9 @@ export declare function deNormalizeDrag(drag: {
52
52
  };
53
53
  /**
54
54
  * Scale an iOS POINT coordinate into the PIXEL space, used by the element path:
55
- * `idb` reports a11y frames (and so the tappable bounds-center) in POINTS, but
55
+ * WebDriverAgent reports a11y frames (and so the tappable bounds-center) in POINTS, but
56
56
  * the loop records — and re-normalizes against `dimensions()` — in PIXELS. So
57
- * the element path taps the point-center directly (idb consumes points) yet must
57
+ * the element path taps the point-center directly (WDA consumes points) yet must
58
58
  * RECORD a pixel-center; this converts the one to the other per-axis by the
59
59
  * point→pixel ratio (the @Nx scale). Pure so the conversion is unit-testable
60
60
  * without a simulator. Android needs no analog: its screencap and tap share one
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The backend's vision locator returns NORMALIZED 0-1000 coordinates. A native
5
5
  * device de-normalizes them against a concrete dimension (screencap pixels for
6
- * Android; idb POINTS for the iOS tap, screenshot PIXELS for the iOS record),
6
+ * Android; WDA POINTS for the iOS tap, screenshot PIXELS for the iOS record),
7
7
  * and the loop later re-normalizes the recorded coordinate back to 0-1000.
8
8
  *
9
9
  * Pure and side-effect-free so the round-trip can be unit-tested without a
@@ -32,7 +32,7 @@ export function deNormalizePoint(c, width, height) {
32
32
  * De-normalize a drag's start AND end points (each normalized 0-1000) against
33
33
  * one device dimension into the {start,end} pixel/point pair the drivers feed to
34
34
  * a slow swipe. The drag path is a from→to gesture, so BOTH ends de-normalize
35
- * against the SAME basis (Android: screencap pixels; iOS: idb POINTS). Pure so
35
+ * against the SAME basis (Android: screencap pixels; iOS: WDA POINTS). Pure so
36
36
  * the two-ended de-normalization is unit-testable without a device.
37
37
  */
38
38
  export function deNormalizeDrag(drag, width, height) {
@@ -43,9 +43,9 @@ export function deNormalizeDrag(drag, width, height) {
43
43
  }
44
44
  /**
45
45
  * Scale an iOS POINT coordinate into the PIXEL space, used by the element path:
46
- * `idb` reports a11y frames (and so the tappable bounds-center) in POINTS, but
46
+ * WebDriverAgent reports a11y frames (and so the tappable bounds-center) in POINTS, but
47
47
  * the loop records — and re-normalizes against `dimensions()` — in PIXELS. So
48
- * the element path taps the point-center directly (idb consumes points) yet must
48
+ * the element path taps the point-center directly (WDA consumes points) yet must
49
49
  * RECORD a pixel-center; this converts the one to the other per-axis by the
50
50
  * point→pixel ratio (the @Nx scale). Pure so the conversion is unit-testable
51
51
  * without a simulator. Android needs no analog: its screencap and tap share one
@@ -18,7 +18,7 @@ import type { BrowserSession } from "./browser.js";
18
18
  * One observation of the target's current state.
19
19
  *
20
20
  * `accessibilityTree` is populated by the browser (CDP) and by native targets
21
- * (uiautomator / idb describe-all), serialized to the same `[id] role "name"`
21
+ * (uiautomator / WDA /source), serialized to the same `[id] role "name"`
22
22
  * format the backend DOMLocator reasons over; it's "" only when a native dump
23
23
  * fails or yields a sparse tree, which makes the backend take its vision branch.
24
24
  * `url` is browser-only ("" for native). `tabs` is browser-only and empty for
@@ -130,7 +130,7 @@ export declare class BrowserDevice implements SimulationDevice {
130
130
  /**
131
131
  * Build the device for a platform. `web`/`browser`/`""` → Playwright
132
132
  * `BrowserDevice`; `android` → `AndroidDevice` (adb); `ios` → `IOSDevice`
133
- * (simctl + idb). The native cases are dynamically imported so the browser path
133
+ * (simctl + WebDriverAgent). The native cases are dynamically imported so the browser path
134
134
  * never pulls in the adb/simctl modules.
135
135
  */
136
136
  export declare function createDevice(platform: string, opts: {
@@ -116,7 +116,7 @@ export class BrowserDevice {
116
116
  /**
117
117
  * Build the device for a platform. `web`/`browser`/`""` → Playwright
118
118
  * `BrowserDevice`; `android` → `AndroidDevice` (adb); `ios` → `IOSDevice`
119
- * (simctl + idb). The native cases are dynamically imported so the browser path
119
+ * (simctl + WebDriverAgent). The native cases are dynamically imported so the browser path
120
120
  * never pulls in the adb/simctl modules.
121
121
  */
122
122
  export async function createDevice(platform, opts) {
@@ -1,14 +1,15 @@
1
1
  /**
2
- * IOSDevice — drives a local iOS simulator via `xcrun simctl` + `idb`,
2
+ * IOSDevice — drives a local iOS simulator via `xcrun simctl` (lifecycle +
3
+ * screenshot) and WebDriverAgent/XCUITest (UI + a11y; see xcuitest.ts),
3
4
  * implementing the SimulationDevice surface the loop expects. Mirrors
4
5
  * AndroidDevice; the one substantive difference is the coordinate space.
5
6
  *
6
7
  * Two resolution paths, mirroring the browser:
7
- * - ELEMENT (preferred): observe() reads the `idb ui describe-all` a11y tree,
8
- * serializes it to the `[id] role "label"` string the backend DOMLocator
9
- * reasons over, and keeps a local `shortId → bounds` map (bounds in POINTS).
10
- * The backend returns a `node_id`; executeAction() looks the bounds up and
11
- * taps the element's CENTER.
8
+ * - ELEMENT (preferred): observe() reads WDA's `/source` a11y tree, serializes
9
+ * it to the `[id] role "label"` string the backend DOMLocator reasons over,
10
+ * and keeps a local `shortId → bounds` map (bounds in POINTS). The backend
11
+ * returns a `node_id`; executeAction() looks the bounds up and taps the
12
+ * element's CENTER.
12
13
  * - VISION (fallback): when the tree is empty/sparse, observe() returns an
13
14
  * empty tree so the backend takes its vision branch and returns NORMALIZED
14
15
  * 0-1000 coordinates. Also taken per-action whenever node_id is absent.
@@ -16,7 +17,7 @@
16
17
  * COORDINATE SPACE — two spaces, the key difference from Android (where
17
18
  * screencap and tap share one pixel space):
18
19
  * `simctl io booted screenshot` is in PIXELS (e.g. 1179x2556 @3x), but
19
- * `idb ui tap/swipe` AND the `describe-all` a11y frames are POINTS (393x852).
20
+ * WDA taps/swipes AND the `/source` a11y frames are POINTS (393x852).
20
21
  * The invariant in BOTH paths: TAP in points, RECORD in pixels, because the
21
22
  * loop re-normalizes the recorded coord against dimensions() (PIXELS).
22
23
  * - VISION: tap pt = round(n/1000 * pointSize); record px = round(n/1000 * pixelSize).
@@ -46,6 +47,8 @@ export declare class IOSDevice implements SimulationDevice {
46
47
  private readonly appPath;
47
48
  /** udid of the single booted simulator we drive. */
48
49
  private udid;
50
+ /** Set once the WebDriverAgent runner is up, so the startup note logs once. */
51
+ private wdaStarted;
49
52
  /** POINT size — what idb ui tap/swipe consume (de-normalization basis for TAPS). */
50
53
  private pointWidth;
51
54
  private pointHeight;
@@ -77,7 +80,7 @@ export declare class IOSDevice implements SimulationDevice {
77
80
  private refreshScreen;
78
81
  observe(): Promise<DeviceObservation>;
79
82
  /**
80
- * Read + serialize the idb describe-all a11y tree (bounds in POINTS). Any
83
+ * Read + serialize WDA's /source a11y tree (bounds in POINTS). Any
81
84
  * failure (retries exhausted on a trivial tree, parse error) degrades to an
82
85
  * empty tree so the backend falls back to vision — a missing tree must never
83
86
  * abort the observation.
@@ -89,7 +92,7 @@ export declare class IOSDevice implements SimulationDevice {
89
92
  width: number;
90
93
  height: number;
91
94
  };
92
- /** Normalized 0-1000 → POINT space (idb ui tap/swipe take points). */
95
+ /** Normalized 0-1000 → POINT space (WDA taps/swipes take points). */
93
96
  private toPoints;
94
97
  /** Normalized 0-1000 → PIXEL space (the recorded/reported coord). */
95
98
  private toPixels;
@@ -140,6 +143,20 @@ export declare class IOSDevice implements SimulationDevice {
140
143
  * do drive the system gesture) when no back button is visible.
141
144
  */
142
145
  private navigateBack;
146
+ /**
147
+ * Best-effort open of an iOS system panel by swiping down from the top edge.
148
+ * iOS has no `cmd statusbar` equivalent, so on a Face-ID layout:
149
+ * - notifications → Notification Center: swipe down from the top-CENTER.
150
+ * - quick_settings → Control Center: swipe down from the top-RIGHT corner.
151
+ * Coordinates are POINTS (idb consumes points; see toPoints()/the swipe()
152
+ * helper). This is FLAKY on the simulator — idb's synthetic touch frequently
153
+ * doesn't trigger the system edge gesture (the same limitation navigateBack's
154
+ * edge-swipe hits). We compare a before/after screenshot and log LOUDLY when
155
+ * the screen didn't change, rather than silently reporting success, so a
156
+ * no-op is visible in the run. The executeAction caller still returns
157
+ * success:true (the gesture was attempted); the loud log is the signal.
158
+ */
159
+ private openSystemPanel;
143
160
  /**
144
161
  * The nav-bar back button: the leading (leftmost) actionable button in the
145
162
  * top nav-bar band. iOS HIG guarantees "back" is the leading nav item in a