@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.
- package/README.md +4 -0
- package/dist/commands/doctor.js +21 -11
- package/dist/commands/iteration.js +13 -4
- package/dist/commands/study-run.js +12 -12
- package/dist/commands/study-screenshots.js +15 -12
- package/dist/commands/study.js +22 -3
- package/dist/lib/docs.js +139 -7
- package/dist/lib/local-sim/adb.d.ts +19 -2
- package/dist/lib/local-sim/adb.js +71 -23
- package/dist/lib/local-sim/device-pool.d.ts +85 -0
- package/dist/lib/local-sim/device-pool.js +316 -0
- package/dist/lib/local-sim/device.d.ts +4 -0
- package/dist/lib/local-sim/device.js +19 -1
- package/dist/lib/local-sim/emulator.d.ts +50 -0
- package/dist/lib/local-sim/emulator.js +189 -0
- package/dist/lib/local-sim/install.js +23 -3
- package/dist/lib/local-sim/ios.d.ts +26 -1
- package/dist/lib/local-sim/ios.js +51 -11
- package/dist/lib/local-sim/loop.js +112 -9
- package/dist/lib/local-sim/screen-signature.js +4 -0
- package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
- package/dist/lib/local-sim/simctl-provision.js +89 -0
- package/dist/lib/local-sim/simctl.d.ts +6 -4
- package/dist/lib/local-sim/simctl.js +18 -5
- package/dist/lib/local-sim/xcuitest.d.ts +15 -1
- package/dist/lib/local-sim/xcuitest.js +22 -6
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +5 -2
- package/dist/lib/upload.d.ts +27 -0
- package/dist/lib/upload.js +108 -11
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
371
|
-
//
|
|
372
|
-
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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",
|
|
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
|
|
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. */
|