@ishlabs/cli 0.27.0 → 0.27.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.
@@ -5,9 +5,6 @@
5
5
  */
6
6
  import { existsSync } from "node:fs";
7
7
  import { chromium } from "playwright-core";
8
- // Deep-import the bundled registry so this works in both the npm-install path
9
- // and the standalone bun binary (which has no `npx` to spawn).
10
- import { registry } from "playwright-core/lib/server/registry/index";
11
8
  // playwright-core's userAgent module does `require("../../../package.json")`
12
9
  // at runtime to read its version. bun's --compile bundler is unreliable about
13
10
  // embedding that JSON, which causes install to crash in the standalone binary
@@ -17,6 +14,13 @@ import { registry } from "playwright-core/lib/server/registry/index";
17
14
  // Keep this string in sync with the playwright-core dep in package.json. It
18
15
  // only feeds the User-Agent string sent to download CDN, so a slight mismatch
19
16
  // is harmless.
17
+ //
18
+ // package.json pins playwright-core EXACTLY (no ^) because this module's
19
+ // registry deep import and scripts/patch-playwright-core.mjs both depend on
20
+ // playwright-core internals that change between minor versions — 1.60.0
21
+ // removed `./lib/server/registry/index` from the exports map, which broke
22
+ // every fresh `npm install @ishlabs/cli` while CI (lockfile-pinned to 1.59.1)
23
+ // stayed green. Bump the pin and this constant together, deliberately.
20
24
  const PLAYWRIGHT_CORE_VERSION = "1.59.1";
21
25
  if (!process.env.PW_VERSION_OVERRIDE) {
22
26
  process.env.PW_VERSION_OVERRIDE = PLAYWRIGHT_CORE_VERSION;
@@ -34,6 +38,22 @@ export async function installBrowser(quiet = false) {
34
38
  const log = (msg) => { if (!quiet)
35
39
  console.error(msg); };
36
40
  log("Installing Chromium for local simulations (~120 MB)...");
41
+ // Deep-import the bundled registry so this works in both the npm-install
42
+ // path and the standalone bun binary (which has no `npx` to spawn). The
43
+ // import is lazy — `playwright-core/lib/server/registry/index` is not a
44
+ // semver-stable subpath (1.60.0 dropped it from the exports map), so a
45
+ // top-level import would make this whole module unloadable and take
46
+ // `isBrowserInstalled()` / doctor down with it.
47
+ let registry;
48
+ try {
49
+ ({ registry } = await import("playwright-core/lib/server/registry/index"));
50
+ }
51
+ catch (err) {
52
+ const detail = err instanceof Error ? err.message : String(err);
53
+ throw new Error(`Failed to load the Playwright browser installer (${detail}). ` +
54
+ `The installed playwright-core version is incompatible with this CLI — ` +
55
+ `expected ${PLAYWRIGHT_CORE_VERSION}. Reinstall the CLI to fix.`);
56
+ }
37
57
  try {
38
58
  const executables = registry.resolveBrowsers(["chromium"], {});
39
59
  await registry.install(executables, { force: false });
@@ -37,16 +37,41 @@ export interface IosDeviceOptions {
37
37
  bundleId?: string;
38
38
  /** Local .app path to install before the run, or a bundle id to launch. */
39
39
  appPath?: string;
40
+ /** Simulator udid to drive. When set (pooled/parallel run), skip booted-sim discovery. */
41
+ udid?: string;
42
+ /** WDA port for THIS device. When set, the runner binds it instead of DEFAULT_PORT (concurrent pool). */
43
+ wdaPort?: number;
40
44
  contextValues: ContextValue[];
41
45
  log?: (msg: string) => void;
42
46
  }
47
+ /**
48
+ * The run-up-front caveat for native state reset, or null when none is needed.
49
+ *
50
+ * A terminate+relaunch does NOT clear app data, so state one participant creates
51
+ * (a reminder, a saved record) can leak into the next. When we hold an
52
+ * installable `.app` the runner uninstall+reinstalls per participant (clean), so
53
+ * no warning is needed. A bare bundle-id / system app (e.g.
54
+ * `com.apple.reminders`) can't be reinstalled — so for a multi-participant run
55
+ * against one, warn that state may persist. A single-participant run has nothing
56
+ * to leak into, so it stays quiet.
57
+ *
58
+ * Lives here (not on the per-participant IOSDevice, which is recreated each
59
+ * participant) because the decision needs the run-scoped cohort size — the loop
60
+ * owns that. Pure + exported so it can be unit-tested without a simulator.
61
+ *
62
+ * @param reinstallable true when the target is a local `.app`/`.apk` we reinstall
63
+ * @param participantCount number of participants in this run
64
+ */
65
+ export declare function nativeStateResetWarning(reinstallable: boolean, participantCount: number): string | null;
43
66
  export declare class IOSDevice implements SimulationDevice {
44
67
  private readonly contextValues;
45
68
  private readonly log;
46
69
  private bundleId;
47
70
  private readonly appPath;
48
- /** udid of the single booted simulator we drive. */
71
+ /** udid of the simulator we drive. Set from opts (pooled) or discovered (single-device). */
49
72
  private udid;
73
+ /** WDA port for this device when pooled; undefined → DEFAULT_PORT (single-device). */
74
+ private readonly wdaPort;
50
75
  /** Set once the WebDriverAgent runner is up, so the startup note logs once. */
51
76
  private wdaStarted;
52
77
  /** POINT size — what idb ui tap/swipe consume (de-normalization basis for TAPS). */
@@ -31,7 +31,7 @@
31
31
  * backend never converts coords with screen_width/height.
32
32
  */
33
33
  import { resolveTextValue } from "./actions.js";
34
- import { requireOneBootedSimulator, screenshotPng, terminateApp, launchApp, installApp, isAppInstalled, bundleIdFromApp, appBuildFromSimulator, } from "./simctl.js";
34
+ import { requireOneBootedSimulator, screenshotPng, terminateApp, launchApp, installApp, uninstallApp, isAppInstalled, bundleIdFromApp, appBuildFromSimulator, } from "./simctl.js";
35
35
  // iOS UI interaction + a11y run through WebDriverAgent (XCUITest), not idb.
36
36
  import { ensureWda, closeWda, describeScreen, describeAll, activeBundleId, uiTap, uiLongPress, uiSwipe, uiText, uiKey, HID_KEY_RETURN, } from "./xcuitest.js";
37
37
  import { isLocalPath } from "../upload.js";
@@ -58,13 +58,42 @@ const NON_BACK_LEADING_LABELS = new Set([
58
58
  async function settle(ms = POST_GESTURE_SETTLE_MS) {
59
59
  await new Promise((r) => setTimeout(r, ms));
60
60
  }
61
+ /**
62
+ * The run-up-front caveat for native state reset, or null when none is needed.
63
+ *
64
+ * A terminate+relaunch does NOT clear app data, so state one participant creates
65
+ * (a reminder, a saved record) can leak into the next. When we hold an
66
+ * installable `.app` the runner uninstall+reinstalls per participant (clean), so
67
+ * no warning is needed. A bare bundle-id / system app (e.g.
68
+ * `com.apple.reminders`) can't be reinstalled — so for a multi-participant run
69
+ * against one, warn that state may persist. A single-participant run has nothing
70
+ * to leak into, so it stays quiet.
71
+ *
72
+ * Lives here (not on the per-participant IOSDevice, which is recreated each
73
+ * participant) because the decision needs the run-scoped cohort size — the loop
74
+ * owns that. Pure + exported so it can be unit-tested without a simulator.
75
+ *
76
+ * @param reinstallable true when the target is a local `.app`/`.apk` we reinstall
77
+ * @param participantCount number of participants in this run
78
+ */
79
+ export function nativeStateResetWarning(reinstallable, participantCount) {
80
+ if (reinstallable || participantCount <= 1)
81
+ return null;
82
+ return ("Note: app data is NOT reset between participants — the target is an installed " +
83
+ "bundle id (e.g. a system app), which can't be reinstalled, so state an earlier " +
84
+ "participant creates may persist into the next and skew results. Pass " +
85
+ "--app <path-to.app> to enable a clean reinstall per participant, or run one " +
86
+ "participant per study for a guaranteed clean start.");
87
+ }
61
88
  export class IOSDevice {
62
89
  contextValues;
63
90
  log;
64
91
  bundleId;
65
92
  appPath;
66
- /** udid of the single booted simulator we drive. */
93
+ /** udid of the simulator we drive. Set from opts (pooled) or discovered (single-device). */
67
94
  udid = "";
95
+ /** WDA port for this device when pooled; undefined → DEFAULT_PORT (single-device). */
96
+ wdaPort;
68
97
  /** Set once the WebDriverAgent runner is up, so the startup note logs once. */
69
98
  wdaStarted = false;
70
99
  /** POINT size — what idb ui tap/swipe consume (de-normalization basis for TAPS). */
@@ -91,9 +120,13 @@ export class IOSDevice {
91
120
  this.log = opts.log ?? (() => { });
92
121
  this.bundleId = opts.bundleId ?? null;
93
122
  this.appPath = opts.appPath;
123
+ this.udid = opts.udid ?? "";
124
+ this.wdaPort = opts.wdaPort;
94
125
  }
95
126
  async launchOrReset(target) {
96
- this.udid = await requireOneBootedSimulator();
127
+ // Pooled/parallel runs pin a udid via opts; the single-device path discovers
128
+ // the one booted simulator (and still rejects >1, preserving today's UX).
129
+ this.udid = this.udid || (await requireOneBootedSimulator());
97
130
  // First call: install the .app (if --app is a local path) and resolve the
98
131
  // bundle id to terminate/relaunch on. `target` is the iteration's platform
99
132
  // target (a bundle id) when no --app is supplied. Throws (rather than
@@ -108,11 +141,15 @@ export class IOSDevice {
108
141
  if (!this.wdaStarted) {
109
142
  this.log("Starting the iOS automation runner (WebDriverAgent); first launch can take ~30-60s...");
110
143
  }
111
- await ensureWda(this.udid);
144
+ await ensureWda(this.udid, { port: this.wdaPort });
112
145
  this.wdaStarted = true;
113
146
  // Prime screen geometry (points) before the first de-normalization.
114
147
  await this.refreshScreen();
115
- // Per-participant reset: terminate then relaunch from a clean state.
148
+ // Per-participant reset: terminate then relaunch. For a local .app target,
149
+ // resolveBundleId (above) already uninstall+reinstalled this fresh device's
150
+ // app, so each participant starts from clean data. A bundle-id / system-app
151
+ // target can't be reinstalled — runLocalSimulations warns once up front that
152
+ // its state may persist between participants (see nativeStateResetWarning).
116
153
  await terminateApp(this.udid, bundleId);
117
154
  await launchApp(this.udid, bundleId);
118
155
  await settle(1500); // cold start needs longer than a gesture settle
@@ -156,7 +193,10 @@ export class IOSDevice {
156
193
  throw new Error(`Could not read CFBundleIdentifier from "${appSpec}/Info.plist". ` +
157
194
  `Pass --app <bundle.id> explicitly if the .app layout is unusual.`);
158
195
  }
159
- this.log(`Installing ${appSpec} (${id})...`);
196
+ // Uninstall first so a build left over from a prior run doesn't carry its
197
+ // data into participant 0 — installApp alone preserves the data container.
198
+ this.log(`Installing a clean build of ${appSpec} (${id})...`);
199
+ await uninstallApp(this.udid, id);
160
200
  await installApp(this.udid, appSpec);
161
201
  return id;
162
202
  }
@@ -183,7 +223,7 @@ export class IOSDevice {
183
223
  // failure → the navTitle-only coarse token).
184
224
  await this.refreshScreen();
185
225
  const [png, tree, bundleId] = await Promise.all([
186
- screenshotPng(),
226
+ screenshotPng(this.udid),
187
227
  this.dumpTree(),
188
228
  activeBundleId(this.udid),
189
229
  ]);
@@ -238,14 +278,14 @@ export class IOSDevice {
238
278
  }
239
279
  }
240
280
  async captureScreenshot() {
241
- const png = await screenshotPng();
281
+ const png = await screenshotPng(this.udid);
242
282
  return png.toString("base64");
243
283
  }
244
284
  async captureScreenshotJpeg() {
245
285
  // simctl screenshot only emits PNG. We return the PNG bytes; the upload/
246
286
  // record path treats them as opaque image bytes (PDQ frame-matching works
247
287
  // on PNG). The loop labels native uploads image/png.
248
- return screenshotPng();
288
+ return screenshotPng(this.udid);
249
289
  }
250
290
  dimensions() {
251
291
  // PIXELS — the space the loop re-normalizes the recorded coord against.
@@ -549,7 +589,7 @@ export class IOSDevice {
549
589
  * success:true (the gesture was attempted); the loud log is the signal.
550
590
  */
551
591
  async openSystemPanel(panel) {
552
- const before = await screenshotPng();
592
+ const before = await screenshotPng(this.udid);
553
593
  const w = this.pointWidth;
554
594
  const h = this.pointHeight;
555
595
  // Start ON the top edge and travel a third of the screen down. Control
@@ -562,7 +602,7 @@ export class IOSDevice {
562
602
  await settle();
563
603
  // Loudly surface a no-op: the simulator's synthetic touch often can't drive
564
604
  // the system edge gesture. An identical screenshot means the panel didn't open.
565
- const after = await screenshotPng();
605
+ const after = await screenshotPng(this.udid);
566
606
  if (before.equals(after)) {
567
607
  this.log(`open_system_panel (${panel}): top-edge swipe produced no visible change — ` +
568
608
  `the simulator's synthetic touch likely didn't trigger the system gesture (flaky on the simulator).`);
@@ -10,6 +10,10 @@ import { launchSharedBrowser, FULL_PAGE_HEIGHT_CAP_PX_MOBILE, FULL_PAGE_HEIGHT_C
10
10
  import { uploadScreenshot } from "./upload.js";
11
11
  import { detectNoVisibleChange, describeAction, classifyStepKind } from "./actions.js";
12
12
  import { createDevice } from "./device.js";
13
+ import { nativeStateResetWarning } from "./ios.js";
14
+ import { provisionDevicePool, maxConcurrentDevices, totalMemBytes, PER_DEVICE_MB, } from "./device-pool.js";
15
+ import { listOnlineSerials } from "./adb.js";
16
+ import { listAvds } from "./emulator.js";
13
17
  import pkg from "../../../package.json" with { type: "json" };
14
18
  import { enableDebug, isDebugEnabled, debugRawResponse, debugNormalizedActions, debugActionExecution, debugForwards, debugStepSummary, debugRecord, } from "./debug.js";
15
19
  /**
@@ -214,13 +218,59 @@ export async function runLocalSimulations(client, opts) {
214
218
  log("\nCancelling after current step...");
215
219
  };
216
220
  process.on("SIGINT", onSigint);
217
- // Native runs share ONE physical device (emulator / simulator), so they
218
- // can't run in parallel — force sequential regardless of --parallel.
219
221
  const isNativeRun = isNativePlatform(opts.platform);
220
- if (isNativeRun && (opts.parallel ?? 1) > 1) {
221
- log("Native (android/ios) runs drive a single device running sequentially.");
222
+ const requested = opts.parallel ?? opts.participantIds.length;
223
+ // Native (iOS + Android) can drive a POOL of devices concurrently, auto-sized
224
+ // to the host's RAM (and, for Android, the number of AVDs) so a small machine
225
+ // just runs fewer in parallel + queues the rest — never errors. Browser uses
226
+ // the requested parallelism directly.
227
+ const NATIVE_PARALLEL_MAX = 5;
228
+ const nativeParallel = isNativeRun && requested > 1 && opts.participantIds.length > 1;
229
+ let concurrency;
230
+ if (nativeParallel) {
231
+ const cap = Math.min(requested, opts.participantIds.length, NATIVE_PARALLEL_MAX);
232
+ if (opts.platform === "android") {
233
+ // Bound by AVDs we can launch + emulators already online.
234
+ let slots = cap;
235
+ try {
236
+ const [avds, online] = await Promise.all([listAvds(), listOnlineSerials()]);
237
+ slots = avds.length + online.length;
238
+ }
239
+ catch {
240
+ /* fall back to cap if the toolchain query fails */
241
+ }
242
+ concurrency = maxConcurrentDevices({
243
+ totalMemBytes: totalMemBytes(),
244
+ perDeviceMb: PER_DEVICE_MB.android,
245
+ requested: cap,
246
+ deviceCount: Math.max(1, slots),
247
+ });
248
+ }
249
+ else {
250
+ concurrency = maxConcurrentDevices({
251
+ totalMemBytes: totalMemBytes(),
252
+ perDeviceMb: PER_DEVICE_MB.ios,
253
+ requested: cap,
254
+ });
255
+ }
256
+ }
257
+ else {
258
+ if (isNativeRun && requested > 1) {
259
+ log("Native parallel needs --parallel >1 and >1 participant; running sequentially.");
260
+ }
261
+ concurrency = isNativeRun ? 1 : requested;
262
+ }
263
+ // iOS: a bundle-id / system-app target can't be reinstalled, so its data
264
+ // isn't cleared between participants (a local .app IS reinstalled per
265
+ // participant — see ios.ts resolveBundleId). Warn once up front when that
266
+ // could skew a multi-participant run. (--app .app ⇒ reinstallable; a bundle id
267
+ // from --app or the iteration's app_artifact ⇒ not.)
268
+ if (opts.platform === "ios") {
269
+ const reinstallable = !!opts.appPath?.toLowerCase().endsWith(".app");
270
+ const warning = nativeStateResetWarning(reinstallable, opts.participantIds.length);
271
+ if (warning)
272
+ log(warning);
222
273
  }
223
- const concurrency = isNativeRun ? 1 : (opts.parallel ?? opts.participantIds.length);
224
274
  // Native runs stamp the app build onto the iteration once — every
225
275
  // participant in a run drives the same installed build, so dedupe to a
226
276
  // single best-effort POST after the first device resolves its app.
@@ -231,6 +281,15 @@ export async function runLocalSimulations(client, opts) {
231
281
  appBuildReported = true;
232
282
  void reportObservedApp(client, opts.iterationId, platform, build, log);
233
283
  };
284
+ // With a device pool, N workers would each read the app build (a redundant
285
+ // simctl listapps / dumpsys per device). Let only the first worker do it.
286
+ let appBuildClaimed = false;
287
+ const claimAppBuild = () => {
288
+ if (appBuildClaimed)
289
+ return false;
290
+ appBuildClaimed = true;
291
+ return true;
292
+ };
234
293
  try {
235
294
  if (concurrency <= 1 || opts.participantIds.length <= 1) {
236
295
  // Sequential execution — each participant owns its own browser
@@ -250,6 +309,47 @@ export async function runLocalSimulations(client, opts) {
250
309
  }
251
310
  }
252
311
  }
312
+ else if (nativeParallel) {
313
+ // Native device pool — N simulators/emulators, one participant per device.
314
+ const deviceWord = opts.platform === "ios" ? "simulator" : "emulator";
315
+ // nativeParallel ⇒ opts.platform is "ios" | "android" (isNativePlatform true).
316
+ const pool = await provisionDevicePool({ platform: opts.platform, size: concurrency, log });
317
+ try {
318
+ const poolN = pool.devices.length;
319
+ log(`\nRunning ${opts.participantIds.length} ${opts.platform} simulations across a pool of ` +
320
+ `${poolN} ${deviceWord}${poolN === 1 ? "" : "s"}` +
321
+ (poolN < Math.min(requested, opts.participantIds.length, NATIVE_PARALLEL_MAX)
322
+ ? " (auto-sized to fit this machine)"
323
+ : "") +
324
+ "...");
325
+ // Launch all participants; each awaits a free device, so actual
326
+ // concurrency is bounded by the pool size. A finished worker releases
327
+ // its device to the next in line.
328
+ const runOne = async (participantId) => {
329
+ if (cancelled)
330
+ return;
331
+ const participantName = opts.participantNames.get(participantId) ?? participantId;
332
+ const participantLog = (msg) => log(`[${participantName}] ${msg}`);
333
+ const device = await pool.claim();
334
+ try {
335
+ participantLog(`Starting on ${deviceWord} ${device.id}`);
336
+ await runSingleSimulation(client, participantId, participantName, opts, participantLog, () => cancelled, reportAppBuild, undefined, device, claimAppBuild);
337
+ log(`Completed: ${participantName}`);
338
+ }
339
+ catch (err) {
340
+ const msg = err instanceof Error ? err.message : String(err);
341
+ log(`Failed: ${participantName} — ${msg}`);
342
+ }
343
+ finally {
344
+ pool.release(device);
345
+ }
346
+ };
347
+ await Promise.allSettled(opts.participantIds.map(runOne));
348
+ }
349
+ finally {
350
+ await pool.teardown();
351
+ }
352
+ }
253
353
  else {
254
354
  // Parallel execution — shared browser, one tab per participant
255
355
  log(`\nRunning ${opts.participantIds.length} simulations in parallel (concurrency: ${concurrency})...`);
@@ -295,7 +395,7 @@ export async function runLocalSimulations(client, opts) {
295
395
  process.off("SIGINT", onSigint);
296
396
  }
297
397
  }
298
- async function runSingleSimulation(client, participantId, participantName, opts, log, isCancelled, onAppBuild, sharedBrowser) {
398
+ async function runSingleSimulation(client, participantId, participantName, opts, log, isCancelled, onAppBuild, sharedBrowser, pooledDevice, claimAppBuild) {
299
399
  // Step 1: Initialize session
300
400
  const initResponse = await client.localSimInit({
301
401
  participant_id: participantId,
@@ -353,6 +453,8 @@ async function runSingleSimulation(client, participantId, participantName, opts,
353
453
  contextValues: session.context_values,
354
454
  sharedBrowser,
355
455
  appPath: opts.appPath,
456
+ deviceId: pooledDevice?.id,
457
+ wdaPort: pooledDevice?.wdaPort,
356
458
  log,
357
459
  });
358
460
  const history = [];
@@ -367,9 +469,10 @@ async function runSingleSimulation(client, participantId, participantName, opts,
367
469
  // Step 3: Launch / navigate the target to its starting point.
368
470
  await device.launchOrReset(launchTarget);
369
471
  // Step 3b: Capture the installed app's build (native only). Best-effort —
370
- // the dedupe in runLocalSimulations keeps this to one POST per run, and a
371
- // failed read or report never disturbs the simulation.
372
- if (onAppBuild) {
472
+ // the dedupe in runLocalSimulations keeps this to one POST per run. With a
473
+ // device pool, only the worker that wins claimAppBuild() reads it (one
474
+ // simctl/dumpsys read total, not one per device).
475
+ if (onAppBuild && (!claimAppBuild || claimAppBuild())) {
373
476
  try {
374
477
  const observed = await device.appBuild?.();
375
478
  if (observed)
@@ -150,6 +150,10 @@ function stableTokenSet(nodes) {
150
150
  }
151
151
  return [...out].sort();
152
152
  }
153
+ // EXPERIMENT: native-consensus-rescue (REVERTED) — a stateful consensus-rescue layer on top of
154
+ // this single-compute signature nets only -2 frag/220 but adds 8 real over-merges. Canonical
155
+ // record: ish-backend docs/experiments/native-consensus-rescue/README.md (stub in docs/experiments/
156
+ // here). Don't re-litigate stateful identity without reading it.
153
157
  /**
154
158
  * Compute the screen signature from this step's parsed tree + coarse inputs.
155
159
  * `value` is `platform|coarse|sha1(tokens)`; `usable` gates whether it's safe to
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Simulator provisioning primitives (boot / clone / shutdown / delete) used by
3
+ * the device pool to stand up N iOS simulators for a parallel run and tear down
4
+ * the ones it created. Kept separate from simctl.ts (the per-device drive layer)
5
+ * so the lifecycle module stays lean.
6
+ *
7
+ * All of these are thin wrappers over `xcrun simctl`. The pool, not this module,
8
+ * decides WHICH devices to clone/boot and tracks ownership for teardown.
9
+ */
10
+ export interface SimDevice {
11
+ udid: string;
12
+ name: string;
13
+ /** "Booted" | "Shutdown" | "Creating" | … */
14
+ state: string;
15
+ /** e.g. com.apple.CoreSimulator.SimDeviceType.iPhone-16 */
16
+ deviceTypeId?: string;
17
+ /** runtime identifier the device lives under, e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-2 */
18
+ runtime: string;
19
+ }
20
+ /**
21
+ * Every device across all runtimes (not just booted), with its runtime folded
22
+ * in from the grouping key. Unavailable devices (missing runtime) are dropped.
23
+ */
24
+ export declare function listAllDevices(): Promise<SimDevice[]>;
25
+ /** Booted devices only (convenience over listAllDevices). */
26
+ export declare function listBootedDevices(): Promise<SimDevice[]>;
27
+ /**
28
+ * Create a fresh simulator of the given device type + runtime and return its
29
+ * udid. Unlike `clone`, `create` works regardless of the source's state (clone
30
+ * refuses while the source is Booted, which it always is when we're reusing it
31
+ * as device 0). A created device is clean — the per-participant app install
32
+ * (IOSDevice.resolveBundleId) and WDA install (ensureWda) populate it.
33
+ */
34
+ export declare function createSimulator(name: string, deviceTypeId: string, runtime: string): Promise<string>;
35
+ /**
36
+ * Clone a SHUTDOWN simulator (carries installed apps; faster than create). Not
37
+ * used by the pool today because the reuse source is booted — kept for callers
38
+ * that hold a shut-down source.
39
+ */
40
+ export declare function cloneDevice(sourceUdid: string, name: string): Promise<string>;
41
+ /**
42
+ * Boot a simulator and wait until it's fully booted. `bootstatus -b` boots the
43
+ * device if needed and blocks until ready, so it's a one-shot "boot and wait".
44
+ */
45
+ export declare function bootDevice(udid: string): Promise<void>;
46
+ /** Power down a simulator. Tolerant of an already-shutdown device. */
47
+ export declare function shutdownDevice(udid: string): Promise<void>;
48
+ /** Delete a (cloned, disposable) simulator. Shut it down first if needed. */
49
+ export declare function deleteDevice(udid: string): Promise<void>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Simulator provisioning primitives (boot / clone / shutdown / delete) used by
3
+ * the device pool to stand up N iOS simulators for a parallel run and tear down
4
+ * the ones it created. Kept separate from simctl.ts (the per-device drive layer)
5
+ * so the lifecycle module stays lean.
6
+ *
7
+ * All of these are thin wrappers over `xcrun simctl`. The pool, not this module,
8
+ * decides WHICH devices to clone/boot and tracks ownership for teardown.
9
+ */
10
+ import { simctl, IosError } from "./simctl.js";
11
+ /**
12
+ * Every device across all runtimes (not just booted), with its runtime folded
13
+ * in from the grouping key. Unavailable devices (missing runtime) are dropped.
14
+ */
15
+ export async function listAllDevices() {
16
+ const out = await simctl(["list", "devices", "-j"], 30_000);
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(out);
20
+ }
21
+ catch {
22
+ throw new IosError("Could not parse `simctl list devices -j` output.");
23
+ }
24
+ const rows = [];
25
+ for (const [runtime, devices] of Object.entries(parsed.devices ?? {})) {
26
+ for (const d of devices) {
27
+ if (d.isAvailable === false)
28
+ continue;
29
+ rows.push({
30
+ udid: d.udid,
31
+ name: d.name,
32
+ state: d.state,
33
+ deviceTypeId: d.deviceTypeIdentifier,
34
+ runtime,
35
+ });
36
+ }
37
+ }
38
+ return rows;
39
+ }
40
+ /** Booted devices only (convenience over listAllDevices). */
41
+ export async function listBootedDevices() {
42
+ return (await listAllDevices()).filter((d) => d.state === "Booted");
43
+ }
44
+ /**
45
+ * Create a fresh simulator of the given device type + runtime and return its
46
+ * udid. Unlike `clone`, `create` works regardless of the source's state (clone
47
+ * refuses while the source is Booted, which it always is when we're reusing it
48
+ * as device 0). A created device is clean — the per-participant app install
49
+ * (IOSDevice.resolveBundleId) and WDA install (ensureWda) populate it.
50
+ */
51
+ export async function createSimulator(name, deviceTypeId, runtime) {
52
+ const out = await simctl(["create", name, deviceTypeId, runtime], 120_000);
53
+ const udid = out.trim();
54
+ if (!/^[0-9A-Fa-f-]{36}$/.test(udid)) {
55
+ throw new IosError(`simctl create did not return a udid (got: ${udid.slice(0, 80)})`);
56
+ }
57
+ return udid;
58
+ }
59
+ /**
60
+ * Clone a SHUTDOWN simulator (carries installed apps; faster than create). Not
61
+ * used by the pool today because the reuse source is booted — kept for callers
62
+ * that hold a shut-down source.
63
+ */
64
+ export async function cloneDevice(sourceUdid, name) {
65
+ const out = await simctl(["clone", sourceUdid, name], 120_000);
66
+ const udid = out.trim();
67
+ if (!/^[0-9A-Fa-f-]{36}$/.test(udid)) {
68
+ throw new IosError(`simctl clone did not return a udid (got: ${udid.slice(0, 80)})`);
69
+ }
70
+ return udid;
71
+ }
72
+ /**
73
+ * Boot a simulator and wait until it's fully booted. `bootstatus -b` boots the
74
+ * device if needed and blocks until ready, so it's a one-shot "boot and wait".
75
+ */
76
+ export async function bootDevice(udid) {
77
+ // `boot` errors if already booted; swallow that, then bootstatus waits.
78
+ await simctl(["boot", udid], 60_000).catch(() => { });
79
+ await simctl(["bootstatus", udid, "-b"], 180_000);
80
+ }
81
+ /** Power down a simulator. Tolerant of an already-shutdown device. */
82
+ export async function shutdownDevice(udid) {
83
+ await simctl(["shutdown", udid], 60_000).catch(() => { });
84
+ }
85
+ /** Delete a (cloned, disposable) simulator. Shut it down first if needed. */
86
+ export async function deleteDevice(udid) {
87
+ await shutdownDevice(udid);
88
+ await simctl(["delete", udid], 60_000);
89
+ }
@@ -39,14 +39,16 @@ export interface IosScreen {
39
39
  density: number;
40
40
  }
41
41
  /**
42
- * Capture the booted simulator's screen as PNG bytes via
43
- * `simctl io booted screenshot`. simctl writes to a file path (no reliable
44
- * stdout in current Xcode), so we round-trip through a temp file.
42
+ * Capture a simulator's screen as PNG bytes via `simctl io <udid> screenshot`.
43
+ * simctl writes to a file path (no reliable stdout in current Xcode), so we
44
+ * round-trip through a temp file. Targets an explicit udid (not the "booted"
45
+ * literal) so concurrent pooled devices each screenshot the right simulator.
45
46
  */
46
- export declare function screenshotPng(): Promise<Buffer>;
47
+ export declare function screenshotPng(udid: string): Promise<Buffer>;
47
48
  export declare function terminateApp(udid: string, bundleId: string): Promise<void>;
48
49
  export declare function launchApp(udid: string, bundleId: string): Promise<void>;
49
50
  export declare function installApp(udid: string, appPath: string): Promise<void>;
51
+ export declare function uninstallApp(udid: string, bundleId: string): Promise<void>;
50
52
  export declare function isAppInstalled(udid: string, bundleId: string): Promise<boolean>;
51
53
  /**
52
54
  * Read CFBundleIdentifier from a local `.app`'s Info.plist via `plutil`. Lets us
@@ -82,15 +82,16 @@ export async function requireOneBootedSimulator() {
82
82
  }
83
83
  // --- Screenshot (PIXELS) ---
84
84
  /**
85
- * Capture the booted simulator's screen as PNG bytes via
86
- * `simctl io booted screenshot`. simctl writes to a file path (no reliable
87
- * stdout in current Xcode), so we round-trip through a temp file.
85
+ * Capture a simulator's screen as PNG bytes via `simctl io <udid> screenshot`.
86
+ * simctl writes to a file path (no reliable stdout in current Xcode), so we
87
+ * round-trip through a temp file. Targets an explicit udid (not the "booted"
88
+ * literal) so concurrent pooled devices each screenshot the right simulator.
88
89
  */
89
- export async function screenshotPng() {
90
+ export async function screenshotPng(udid) {
90
91
  const dir = await mkdtemp(join(tmpdir(), "ish-ios-shot-"));
91
92
  const path = join(dir, "shot.png");
92
93
  try {
93
- await simctl(["io", "booted", "screenshot", path], SCREENSHOT_TIMEOUT_MS);
94
+ await simctl(["io", udid, "screenshot", path], SCREENSHOT_TIMEOUT_MS);
94
95
  return await readFile(path);
95
96
  }
96
97
  finally {
@@ -117,6 +118,18 @@ export async function installApp(udid, appPath) {
117
118
  // Simulator builds aren't code-signed; `simctl install` just stages the .app.
118
119
  await simctl(["install", udid, appPath], 180_000);
119
120
  }
121
+ export async function uninstallApp(udid, bundleId) {
122
+ // Removes the app AND its data container — this is what actually wipes state
123
+ // between participants (a terminate+relaunch leaves the data store intact).
124
+ // Uninstalling an app that isn't installed exits non-zero; swallow it so a
125
+ // first-participant "reset" is a no-op rather than a failure.
126
+ try {
127
+ await simctl(["uninstall", udid, bundleId], 60_000);
128
+ }
129
+ catch {
130
+ // not installed — nothing to remove
131
+ }
132
+ }
120
133
  export async function isAppInstalled(udid, bundleId) {
121
134
  // `simctl listapps` emits a plist of installed bundles; a substring check on
122
135
  // the quoted bundle id is enough to confirm presence.
@@ -18,6 +18,19 @@
18
18
  * and reuses an already-running runner.
19
19
  */
20
20
  import { type IosScreen } from "./simctl.js";
21
+ /**
22
+ * The Return keystroke we feed to WDA's `/wda/keys` for a submit.
23
+ *
24
+ * NOT the W3C ENTER code (U+E007): WDA's `/wda/keys` does NOT interpret the
25
+ * WebDriver special-key PUA codepoints \u2014 it types them LITERALLY. On the
26
+ * simulator U+E007 renders as a running-shoe emoji, so a submit appended a
27
+ * stray `\uD83D\uDC5F` to the field (e.g. a search for `Photos` became `Photos\uD83D\uDC5F`,
28
+ * returning no results and derailing the run) AND never actually submitted.
29
+ * A plain newline is what WDA's keyboard treats as Return \u2014 it submits
30
+ * single-line fields and inserts a line break in multiline ones, with no glyph.
31
+ * Verified on a booted iOS 18 simulator (Settings search).
32
+ */
33
+ export declare const WDA_RETURN = "\n";
21
34
  interface Session {
22
35
  port: number;
23
36
  baseUrl: string;
@@ -38,6 +51,7 @@ export declare function resolveWdaBundle(): Promise<string>;
38
51
  */
39
52
  export declare function ensureWda(udid: string, opts?: {
40
53
  bundleId?: string;
54
+ port?: number;
41
55
  }): Promise<Session>;
42
56
  /** Tear down the WDA session for `udid` (the runner is left for the next run). */
43
57
  export declare function closeWda(udid: string): Promise<void>;
@@ -59,7 +73,7 @@ export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number
59
73
  export declare function uiText(udid: string, text: string): Promise<void>;
60
74
  /**
61
75
  * Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
62
- * map it to W3C ENTER. Unknown codes are a no-op-safe error.
76
+ * map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
63
77
  */
64
78
  export declare function uiKey(udid: string, keycode: number): Promise<void>;
65
79
  /** Re-export so a future ios.ts can drop the simctl HID constant. */