@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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device pool for parallel native runs. One `ish study run --parallel N` drives
|
|
3
|
+
* a pool of N devices; each worker claim()s a device and release()s it when its
|
|
4
|
+
* participant finishes.
|
|
5
|
+
*
|
|
6
|
+
* Devices come from a pluggable `DeviceProvider` (simulator | local-avd today;
|
|
7
|
+
* redroid | remote later), so the pool itself is provider-agnostic — it owns the
|
|
8
|
+
* claim/release queue, the crash-safe lock, and teardown of only what we started.
|
|
9
|
+
*
|
|
10
|
+
* The pure helpers (createDeviceQueue, allocateWdaPorts, maxConcurrentDevices)
|
|
11
|
+
* are exported and unit-tested without any device.
|
|
12
|
+
*/
|
|
13
|
+
/** Rough per-device RAM for auto-sizing. iOS sims are lighter than emulators. */
|
|
14
|
+
export declare const PER_DEVICE_MB: {
|
|
15
|
+
readonly ios: 1024;
|
|
16
|
+
readonly android: 1536;
|
|
17
|
+
};
|
|
18
|
+
export interface PooledDevice {
|
|
19
|
+
platform: "ios" | "android";
|
|
20
|
+
/** iOS simulator udid, or Android adb serial (e.g. "emulator-5554"). */
|
|
21
|
+
id: string;
|
|
22
|
+
/** iOS only: the WDA port allocated to THIS device. */
|
|
23
|
+
wdaPort?: number;
|
|
24
|
+
/** True when the pool booted/cloned/launched this device (=> we tear it down). */
|
|
25
|
+
startedByUs: boolean;
|
|
26
|
+
/** Android: name of an AVD WE auto-created for this device (deleted on teardown). */
|
|
27
|
+
createdAvd?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `count` distinct ports starting at `base`, skipping any an injected `isFree`
|
|
31
|
+
* marks taken (a stale runner on 8100). Pure — `isFree` defaults to all-free.
|
|
32
|
+
*/
|
|
33
|
+
export declare function allocateWdaPorts(count: number, base?: number, isFree?: (p: number) => boolean): number[];
|
|
34
|
+
/**
|
|
35
|
+
* How many devices we can run concurrently given the host's RAM. This is the
|
|
36
|
+
* "anyone can run it" lever: a weak machine gets fewer (>=1, never errors), the
|
|
37
|
+
* rest queue.
|
|
38
|
+
*
|
|
39
|
+
* Sizes off TOTAL RAM × a utilization fraction, NOT `os.freemem()` —
|
|
40
|
+
* `freemem()` is unreliable cross-platform (macOS reports nearly everything as
|
|
41
|
+
* "used" because file cache is reclaimable-but-counted, so a 64 GB Mac shows
|
|
42
|
+
* ~0 free). Total × utilization is stable and machine-appropriate. Pure so
|
|
43
|
+
* tests can pin the sizing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function maxConcurrentDevices(opts: {
|
|
46
|
+
totalMemBytes: number;
|
|
47
|
+
perDeviceMb: number;
|
|
48
|
+
requested: number;
|
|
49
|
+
/** Available device slots (e.g. AVD count). Omit for unbounded (iOS clones). */
|
|
50
|
+
deviceCount?: number;
|
|
51
|
+
/** Fraction of total RAM we're willing to dedicate to devices. */
|
|
52
|
+
utilization?: number;
|
|
53
|
+
/** Headroom (MB) reserved for the OS + the CLI before counting devices. */
|
|
54
|
+
reserveMb?: number;
|
|
55
|
+
}): number;
|
|
56
|
+
export interface DeviceQueue {
|
|
57
|
+
claim(): Promise<PooledDevice>;
|
|
58
|
+
release(d: PooledDevice): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* FIFO semaphore over a fixed device list: claim() takes a free device or waits;
|
|
62
|
+
* release() hands the device to the next waiter or returns it to the free list.
|
|
63
|
+
* Double-release of an already-free device is a no-op.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createDeviceQueue(devices: PooledDevice[]): DeviceQueue;
|
|
66
|
+
export interface DeviceProvider {
|
|
67
|
+
/** Provision up to `want` devices (reuse existing first, start the shortfall). */
|
|
68
|
+
provision(want: number, log: (m: string) => void): Promise<PooledDevice[]>;
|
|
69
|
+
/** Tear down ONE device this provider started. No-op for reused devices. */
|
|
70
|
+
teardownOne(d: PooledDevice, log: (m: string) => void): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export interface DevicePool {
|
|
73
|
+
readonly devices: readonly PooledDevice[];
|
|
74
|
+
claim(): Promise<PooledDevice>;
|
|
75
|
+
release(d: PooledDevice): void;
|
|
76
|
+
/** Tear down ONLY devices we started; idempotent. Called by the loop's finally. */
|
|
77
|
+
teardown(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
export declare function provisionDevicePool(opts: {
|
|
80
|
+
platform: string;
|
|
81
|
+
size: number;
|
|
82
|
+
log: (m: string) => void;
|
|
83
|
+
}): Promise<DevicePool>;
|
|
84
|
+
/** Total host RAM — the basis for auto-sizing the pool (see maxConcurrentDevices). */
|
|
85
|
+
export declare function totalMemBytes(): number;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device pool for parallel native runs. One `ish study run --parallel N` drives
|
|
3
|
+
* a pool of N devices; each worker claim()s a device and release()s it when its
|
|
4
|
+
* participant finishes.
|
|
5
|
+
*
|
|
6
|
+
* Devices come from a pluggable `DeviceProvider` (simulator | local-avd today;
|
|
7
|
+
* redroid | remote later), so the pool itself is provider-agnostic — it owns the
|
|
8
|
+
* claim/release queue, the crash-safe lock, and teardown of only what we started.
|
|
9
|
+
*
|
|
10
|
+
* The pure helpers (createDeviceQueue, allocateWdaPorts, maxConcurrentDevices)
|
|
11
|
+
* are exported and unit-tested without any device.
|
|
12
|
+
*/
|
|
13
|
+
import { totalmem } from "node:os";
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { devicePoolLockPath } from "../paths.js";
|
|
16
|
+
import { IosError } from "./simctl.js";
|
|
17
|
+
import { listAllDevices, listBootedDevices, createSimulator, bootDevice, deleteDevice, } from "./simctl-provision.js";
|
|
18
|
+
import { listOnlineSerials } from "./adb.js";
|
|
19
|
+
import { listAvds, spawnEmulator, emulatorPorts, waitForBoot, emuKill, cloneAvd, deleteAvd, EmulatorError, } from "./emulator.js";
|
|
20
|
+
/** WDA port range start for pooled iOS devices. */
|
|
21
|
+
const WDA_BASE_PORT = 8100;
|
|
22
|
+
/** Rough per-device RAM for auto-sizing. iOS sims are lighter than emulators. */
|
|
23
|
+
export const PER_DEVICE_MB = { ios: 1024, android: 1536 };
|
|
24
|
+
// --- Pure helpers (unit-tested) ---------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* `count` distinct ports starting at `base`, skipping any an injected `isFree`
|
|
27
|
+
* marks taken (a stale runner on 8100). Pure — `isFree` defaults to all-free.
|
|
28
|
+
*/
|
|
29
|
+
export function allocateWdaPorts(count, base = WDA_BASE_PORT, isFree = () => true) {
|
|
30
|
+
const ports = [];
|
|
31
|
+
for (let p = base; ports.length < count && p < base + 1000; p++) {
|
|
32
|
+
if (isFree(p))
|
|
33
|
+
ports.push(p);
|
|
34
|
+
}
|
|
35
|
+
if (ports.length < count) {
|
|
36
|
+
throw new Error(`could not allocate ${count} free WDA ports from ${base}`);
|
|
37
|
+
}
|
|
38
|
+
return ports;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* How many devices we can run concurrently given the host's RAM. This is the
|
|
42
|
+
* "anyone can run it" lever: a weak machine gets fewer (>=1, never errors), the
|
|
43
|
+
* rest queue.
|
|
44
|
+
*
|
|
45
|
+
* Sizes off TOTAL RAM × a utilization fraction, NOT `os.freemem()` —
|
|
46
|
+
* `freemem()` is unreliable cross-platform (macOS reports nearly everything as
|
|
47
|
+
* "used" because file cache is reclaimable-but-counted, so a 64 GB Mac shows
|
|
48
|
+
* ~0 free). Total × utilization is stable and machine-appropriate. Pure so
|
|
49
|
+
* tests can pin the sizing.
|
|
50
|
+
*/
|
|
51
|
+
export function maxConcurrentDevices(opts) {
|
|
52
|
+
const utilization = opts.utilization ?? 0.5;
|
|
53
|
+
const reserveMb = opts.reserveMb ?? 1536;
|
|
54
|
+
const budgetMb = Math.max(0, (opts.totalMemBytes / (1024 * 1024)) * utilization - reserveMb);
|
|
55
|
+
const byRam = Math.max(1, Math.floor(budgetMb / Math.max(1, opts.perDeviceMb)));
|
|
56
|
+
let n = Math.min(opts.requested, byRam);
|
|
57
|
+
if (opts.deviceCount !== undefined)
|
|
58
|
+
n = Math.min(n, opts.deviceCount);
|
|
59
|
+
return Math.max(1, n); // always at least sequential
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* FIFO semaphore over a fixed device list: claim() takes a free device or waits;
|
|
63
|
+
* release() hands the device to the next waiter or returns it to the free list.
|
|
64
|
+
* Double-release of an already-free device is a no-op.
|
|
65
|
+
*/
|
|
66
|
+
export function createDeviceQueue(devices) {
|
|
67
|
+
const free = [...devices];
|
|
68
|
+
const freeIds = new Set(free.map((d) => d.id));
|
|
69
|
+
const waiters = [];
|
|
70
|
+
return {
|
|
71
|
+
claim() {
|
|
72
|
+
const d = free.shift();
|
|
73
|
+
if (d) {
|
|
74
|
+
freeIds.delete(d.id);
|
|
75
|
+
return Promise.resolve(d);
|
|
76
|
+
}
|
|
77
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
78
|
+
},
|
|
79
|
+
release(d) {
|
|
80
|
+
if (freeIds.has(d.id))
|
|
81
|
+
return; // already free — double release is a no-op
|
|
82
|
+
const waiter = waiters.shift();
|
|
83
|
+
if (waiter) {
|
|
84
|
+
waiter(d); // handed straight to the next worker; stays claimed
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
free.push(d);
|
|
88
|
+
freeIds.add(d.id);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Prefer an iPhone to clone from; a booted one first, else any available. */
|
|
94
|
+
function pickCloneSource(all, booted) {
|
|
95
|
+
return (booted[0] ??
|
|
96
|
+
all.find((d) => /iPhone/i.test(d.name) && d.state !== "Creating") ??
|
|
97
|
+
all[0]);
|
|
98
|
+
}
|
|
99
|
+
function simulatorProvider() {
|
|
100
|
+
return {
|
|
101
|
+
async provision(want, log) {
|
|
102
|
+
const all = await listAllDevices();
|
|
103
|
+
const booted = await listBootedDevices();
|
|
104
|
+
const ports = allocateWdaPorts(want);
|
|
105
|
+
const devices = [];
|
|
106
|
+
// Reuse already-booted simulators first (don't tear these down).
|
|
107
|
+
for (const d of booted.slice(0, want)) {
|
|
108
|
+
devices.push({ platform: "ios", id: d.udid, wdaPort: ports[devices.length], startedByUs: false });
|
|
109
|
+
}
|
|
110
|
+
const shortfall = want - devices.length;
|
|
111
|
+
if (shortfall > 0) {
|
|
112
|
+
const source = pickCloneSource(all, booted);
|
|
113
|
+
if (!source?.deviceTypeId) {
|
|
114
|
+
throw new IosError("No simulator to model the pool on — boot one (Simulator.app) or run `ish check ios`.");
|
|
115
|
+
}
|
|
116
|
+
log(`Creating ${shortfall} simulator(s) (${source.name}) for the pool — first boot can take ~30s each...`);
|
|
117
|
+
for (let i = 0; i < shortfall; i++) {
|
|
118
|
+
const name = `ish-pool-${process.pid}-${i}`;
|
|
119
|
+
const udid = await createSimulator(name, source.deviceTypeId, source.runtime);
|
|
120
|
+
await bootDevice(udid);
|
|
121
|
+
devices.push({ platform: "ios", id: udid, wdaPort: ports[devices.length], startedByUs: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return devices;
|
|
125
|
+
},
|
|
126
|
+
async teardownOne(d, log) {
|
|
127
|
+
if (!d.startedByUs)
|
|
128
|
+
return;
|
|
129
|
+
log(`Removing pooled simulator ${d.id.slice(0, 8)}…`);
|
|
130
|
+
await deleteDevice(d.id);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function localAvdProvider() {
|
|
135
|
+
// Track emulators WE spawned so teardown can SIGKILL them if `emu kill` hangs.
|
|
136
|
+
const spawned = new Map();
|
|
137
|
+
return {
|
|
138
|
+
async provision(want, log) {
|
|
139
|
+
const online = await listOnlineSerials();
|
|
140
|
+
const devices = [];
|
|
141
|
+
for (const serial of online.slice(0, want)) {
|
|
142
|
+
devices.push({ platform: "android", id: serial, startedByUs: false });
|
|
143
|
+
}
|
|
144
|
+
const shortfall = want - devices.length;
|
|
145
|
+
if (shortfall > 0) {
|
|
146
|
+
const avds = await listAvds();
|
|
147
|
+
if (avds.length === 0) {
|
|
148
|
+
throw new EmulatorError("No Android AVDs found — create one in Android Studio › Device Manager. (The pool clones it to as many as you need.)");
|
|
149
|
+
}
|
|
150
|
+
// AVDs we'll launch from. Auto-clone the shortfall from an existing AVD
|
|
151
|
+
// (file-copy, no avdmanager/JDK needed) so you only need ONE AVD.
|
|
152
|
+
const usable = [...avds];
|
|
153
|
+
const createdAvds = [];
|
|
154
|
+
const need = shortfall - usable.length;
|
|
155
|
+
if (need > 0) {
|
|
156
|
+
const source = avds[0];
|
|
157
|
+
log(`Cloning ${need} AVD(s) from "${source}" so --parallel has enough devices...`);
|
|
158
|
+
for (let i = 0; i < need; i++) {
|
|
159
|
+
const name = `ish-pool-avd-${process.pid}-${i}`;
|
|
160
|
+
cloneAvd(source, name);
|
|
161
|
+
createdAvds.push(name);
|
|
162
|
+
usable.push(name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const toLaunch = Math.min(shortfall, usable.length);
|
|
166
|
+
if (toLaunch < shortfall) {
|
|
167
|
+
log(`Only ${usable.length} AVD(s) available; launching ${toLaunch} (create more AVDs or lower --parallel).`);
|
|
168
|
+
}
|
|
169
|
+
// Avoid console ports already taken by reused emulators.
|
|
170
|
+
const usedPorts = new Set(online
|
|
171
|
+
.filter((s) => s.startsWith("emulator-"))
|
|
172
|
+
.map((s) => Number(s.slice("emulator-".length))));
|
|
173
|
+
const ports = emulatorPorts(toLaunch + usedPorts.size)
|
|
174
|
+
.filter((p) => !usedPorts.has(p))
|
|
175
|
+
.slice(0, toLaunch);
|
|
176
|
+
const createdSet = new Set(createdAvds);
|
|
177
|
+
log(`Launching ${toLaunch} headless emulator(s) for the pool — first boot can take ~60s each...`);
|
|
178
|
+
const launched = [];
|
|
179
|
+
for (let i = 0; i < toLaunch; i++) {
|
|
180
|
+
const avd = usable[i];
|
|
181
|
+
const { child, serial } = spawnEmulator(avd, ports[i], { headless: true });
|
|
182
|
+
spawned.set(serial, child);
|
|
183
|
+
launched.push({ serial, avd });
|
|
184
|
+
}
|
|
185
|
+
await Promise.all(launched.map((l) => waitForBoot(l.serial)));
|
|
186
|
+
for (const { serial, avd } of launched) {
|
|
187
|
+
devices.push({
|
|
188
|
+
platform: "android",
|
|
189
|
+
id: serial,
|
|
190
|
+
startedByUs: true,
|
|
191
|
+
...(createdSet.has(avd) ? { createdAvd: avd } : {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return devices;
|
|
196
|
+
},
|
|
197
|
+
async teardownOne(d, log) {
|
|
198
|
+
if (!d.startedByUs)
|
|
199
|
+
return;
|
|
200
|
+
log(`Stopping pooled emulator ${d.id}…`);
|
|
201
|
+
await emuKill(d.id);
|
|
202
|
+
const child = spawned.get(d.id);
|
|
203
|
+
if (child && !child.killed) {
|
|
204
|
+
try {
|
|
205
|
+
child.kill("SIGKILL");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* already gone */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
spawned.delete(d.id);
|
|
212
|
+
if (d.createdAvd)
|
|
213
|
+
deleteAvd(d.createdAvd);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function providerFor(platform) {
|
|
218
|
+
if (platform === "ios")
|
|
219
|
+
return simulatorProvider();
|
|
220
|
+
if (platform === "android")
|
|
221
|
+
return localAvdProvider();
|
|
222
|
+
throw new IosError(`device pool not yet implemented for platform "${platform}"`);
|
|
223
|
+
}
|
|
224
|
+
function pidIsAlive(pid) {
|
|
225
|
+
try {
|
|
226
|
+
process.kill(pid, 0);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function readLock() {
|
|
234
|
+
const p = devicePoolLockPath();
|
|
235
|
+
if (!existsSync(p))
|
|
236
|
+
return null;
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function writeLock(lock) {
|
|
245
|
+
writeFileSync(devicePoolLockPath(), JSON.stringify(lock), { mode: 0o600 });
|
|
246
|
+
}
|
|
247
|
+
function clearLock() {
|
|
248
|
+
try {
|
|
249
|
+
rmSync(devicePoolLockPath(), { force: true });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
/* best effort */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Reap a lock left by a crashed prior run: tear down its started devices. */
|
|
256
|
+
async function reapStaleLock(log) {
|
|
257
|
+
const lock = readLock();
|
|
258
|
+
if (!lock)
|
|
259
|
+
return;
|
|
260
|
+
if (pidIsAlive(lock.pid)) {
|
|
261
|
+
throw new IosError(`Another parallel native run (pid ${lock.pid}) holds the device pool. ` +
|
|
262
|
+
"Wait for it to finish, or kill it and retry.");
|
|
263
|
+
}
|
|
264
|
+
log("Reaping devices from a previous interrupted parallel run...");
|
|
265
|
+
for (const d of lock.devices) {
|
|
266
|
+
if (!d.startedByUs)
|
|
267
|
+
continue;
|
|
268
|
+
try {
|
|
269
|
+
if (d.platform === "ios")
|
|
270
|
+
await deleteDevice(d.id);
|
|
271
|
+
else if (d.platform === "android") {
|
|
272
|
+
await emuKill(d.id);
|
|
273
|
+
if (d.createdAvd)
|
|
274
|
+
deleteAvd(d.createdAvd);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* best effort */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
clearLock();
|
|
282
|
+
}
|
|
283
|
+
export async function provisionDevicePool(opts) {
|
|
284
|
+
await reapStaleLock(opts.log);
|
|
285
|
+
const provider = providerFor(opts.platform);
|
|
286
|
+
const devices = await provider.provision(opts.size, opts.log);
|
|
287
|
+
writeLock({ pid: process.pid, platform: opts.platform, startedAt: Date.now(), devices });
|
|
288
|
+
const queue = createDeviceQueue([...devices]);
|
|
289
|
+
let tornDown = false;
|
|
290
|
+
const pool = {
|
|
291
|
+
devices,
|
|
292
|
+
claim: () => queue.claim(),
|
|
293
|
+
release: (d) => queue.release(d),
|
|
294
|
+
async teardown() {
|
|
295
|
+
if (tornDown)
|
|
296
|
+
return;
|
|
297
|
+
tornDown = true;
|
|
298
|
+
for (const d of devices) {
|
|
299
|
+
await provider.teardownOne(d, opts.log).catch(() => { });
|
|
300
|
+
}
|
|
301
|
+
clearLock();
|
|
302
|
+
process.off("SIGTERM", onTerm);
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
// The run loop owns SIGINT (sets `cancelled`, then runs teardown in its
|
|
306
|
+
// finally). Add SIGTERM here so a `kill` also cleans up the devices we booted.
|
|
307
|
+
const onTerm = () => {
|
|
308
|
+
void pool.teardown().finally(() => process.exit(1));
|
|
309
|
+
};
|
|
310
|
+
process.once("SIGTERM", onTerm);
|
|
311
|
+
return pool;
|
|
312
|
+
}
|
|
313
|
+
/** Total host RAM — the basis for auto-sizing the pool (see maxConcurrentDevices). */
|
|
314
|
+
export function totalMemBytes() {
|
|
315
|
+
return totalmem();
|
|
316
|
+
}
|
|
@@ -183,5 +183,9 @@ export declare function createDevice(platform: string, opts: {
|
|
|
183
183
|
sharedBrowser?: Browser;
|
|
184
184
|
/** Native: local .apk/.app path to install or a package/bundle id to launch. */
|
|
185
185
|
appPath?: string;
|
|
186
|
+
/** Native pool: the device to drive — iOS simulator udid or Android adb serial. */
|
|
187
|
+
deviceId?: string;
|
|
188
|
+
/** iOS pool: the per-device WDA port. */
|
|
189
|
+
wdaPort?: number;
|
|
186
190
|
log?: (msg: string) => void;
|
|
187
191
|
}): Promise<SimulationDevice>;
|
|
@@ -132,17 +132,35 @@ export async function createDevice(platform, opts) {
|
|
|
132
132
|
}
|
|
133
133
|
case "android": {
|
|
134
134
|
const { AndroidDevice } = await import("./android.js");
|
|
135
|
-
|
|
135
|
+
const dev = new AndroidDevice({
|
|
136
136
|
appPath: opts.appPath,
|
|
137
137
|
contextValues: opts.contextValues,
|
|
138
138
|
log: opts.log,
|
|
139
139
|
});
|
|
140
|
+
if (!opts.deviceId)
|
|
141
|
+
return dev; // single-device path — unchanged
|
|
142
|
+
// Parallel pool: pin every adb call this device makes to its serial.
|
|
143
|
+
// A Proxy runs each method inside withAdbSerial so AsyncLocalStorage
|
|
144
|
+
// carries the serial through all the device's internal adb calls — no
|
|
145
|
+
// per-method threading, and concurrent devices stay isolated.
|
|
146
|
+
const { withAdbSerial } = await import("./adb.js");
|
|
147
|
+
const serial = opts.deviceId;
|
|
148
|
+
return new Proxy(dev, {
|
|
149
|
+
get(target, prop, receiver) {
|
|
150
|
+
const val = Reflect.get(target, prop, receiver);
|
|
151
|
+
return typeof val === "function"
|
|
152
|
+
? (...args) => withAdbSerial(serial, () => val.apply(target, args))
|
|
153
|
+
: val;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
140
156
|
}
|
|
141
157
|
case "ios": {
|
|
142
158
|
const { IOSDevice } = await import("./ios.js");
|
|
143
159
|
return new IOSDevice({
|
|
144
160
|
appPath: opts.appPath,
|
|
145
161
|
contextValues: opts.contextValues,
|
|
162
|
+
udid: opts.deviceId,
|
|
163
|
+
wdaPort: opts.wdaPort,
|
|
146
164
|
log: opts.log,
|
|
147
165
|
});
|
|
148
166
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android emulator (AVD) provisioning for parallel runs: list AVDs, launch
|
|
3
|
+
* tuned/headless emulators on distinct ports, wait for boot, kill them.
|
|
4
|
+
*
|
|
5
|
+
* The pool launches these and tears them down; the per-device adb routing is
|
|
6
|
+
* handled by `withAdbSerial` (see adb.ts), so this module only deals with the
|
|
7
|
+
* emulator PROCESS lifecycle (mirrors how connect.ts spawns/kills cloudflared).
|
|
8
|
+
*/
|
|
9
|
+
import { type ChildProcess } from "node:child_process";
|
|
10
|
+
export declare class EmulatorError extends Error {
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Rewrite the `path=` / `path.rel=` lines of an AVD's pointer `.ini` to point at
|
|
15
|
+
* the clone's dir. Pure (tested).
|
|
16
|
+
*/
|
|
17
|
+
export declare function rewriteAvdPointerIni(text: string, newAvdDirAbs: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Clone an existing AVD by file-copy — NO avdmanager (and therefore no JDK
|
|
20
|
+
* dependency, which `avdmanager` needs and many machines lack). Copies the
|
|
21
|
+
* `.avd` dir minus its running-state, rewrites the pointer `.ini` paths and the
|
|
22
|
+
* `AvdId` / displayname. Turns "you need N AVDs" into "you need ONE".
|
|
23
|
+
*/
|
|
24
|
+
export declare function cloneAvd(source: string, newName: string): void;
|
|
25
|
+
/** Delete a (cloned) AVD's files. Best-effort, no avdmanager needed. */
|
|
26
|
+
export declare function deleteAvd(name: string): void;
|
|
27
|
+
/** AVD names available on this machine (`emulator -list-avds`). */
|
|
28
|
+
export declare function listAvds(): Promise<string[]>;
|
|
29
|
+
export interface SpawnedEmulator {
|
|
30
|
+
child: ChildProcess;
|
|
31
|
+
/** adb serial for this instance, e.g. "emulator-5554". */
|
|
32
|
+
serial: string;
|
|
33
|
+
port: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Launch an AVD as a tuned, lightweight, (by default) headless emulator on a
|
|
37
|
+
* specific console port. The serial is deterministically `emulator-<port>`.
|
|
38
|
+
* The flags keep it small so many fit on a normal machine: no window, software
|
|
39
|
+
* GPU, capped RAM, no boot animation / audio / snapshot writeback.
|
|
40
|
+
*/
|
|
41
|
+
export declare function spawnEmulator(avd: string, port: number, opts?: {
|
|
42
|
+
headless?: boolean;
|
|
43
|
+
memMb?: number;
|
|
44
|
+
}): SpawnedEmulator;
|
|
45
|
+
/** Console ports for N emulators: 5554, 5556, 5558, … (adb wants even ports). */
|
|
46
|
+
export declare function emulatorPorts(count: number, base?: number): number[];
|
|
47
|
+
/** Wait until `serial` is online AND `sys.boot_completed` is 1. */
|
|
48
|
+
export declare function waitForBoot(serial: string, timeoutMs?: number): Promise<void>;
|
|
49
|
+
/** Gracefully stop an emulator (`adb -s <serial> emu kill`). Best-effort. */
|
|
50
|
+
export declare function emuKill(serial: string): Promise<void>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android emulator (AVD) provisioning for parallel runs: list AVDs, launch
|
|
3
|
+
* tuned/headless emulators on distinct ports, wait for boot, kill them.
|
|
4
|
+
*
|
|
5
|
+
* The pool launches these and tears them down; the per-device adb routing is
|
|
6
|
+
* handled by `withAdbSerial` (see adb.ts), so this module only deals with the
|
|
7
|
+
* emulator PROCESS lifecycle (mirrors how connect.ts spawns/kills cloudflared).
|
|
8
|
+
*/
|
|
9
|
+
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, cpSync, rmSync } from "node:fs";
|
|
11
|
+
import { join, basename } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { withAdbSerial, adb, adbShell } from "./adb.js";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
/** Candidate Android SDK roots (env first, then the OS defaults). */
|
|
17
|
+
function sdkRoots() {
|
|
18
|
+
return [
|
|
19
|
+
process.env.ANDROID_HOME,
|
|
20
|
+
process.env.ANDROID_SDK_ROOT,
|
|
21
|
+
join(homedir(), "Library", "Android", "sdk"),
|
|
22
|
+
join(homedir(), "Android", "Sdk"),
|
|
23
|
+
].filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
export class EmulatorError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "EmulatorError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Resolve the `emulator` binary: ISH_EMULATOR → SDK → PATH. */
|
|
32
|
+
function findEmulator() {
|
|
33
|
+
const fromEnv = process.env.ISH_EMULATOR;
|
|
34
|
+
if (fromEnv && existsSync(fromEnv))
|
|
35
|
+
return fromEnv;
|
|
36
|
+
for (const home of sdkRoots()) {
|
|
37
|
+
const p = join(home, "emulator", "emulator");
|
|
38
|
+
if (existsSync(p))
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
execFileSync(process.platform === "win32" ? "where" : "which", ["emulator"], { stdio: "ignore" });
|
|
43
|
+
return "emulator";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The AVD home dir (where `<name>.avd/` + `<name>.ini` live). */
|
|
50
|
+
function avdHome() {
|
|
51
|
+
return process.env.ANDROID_AVD_HOME || join(homedir(), ".android", "avd");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Running-state / lock files we must NOT copy into a clone — the emulator
|
|
55
|
+
* regenerates them on boot, and copying them would carry over locks, snapshots,
|
|
56
|
+
* and the running userdata overlay (also a needless multi-GB copy).
|
|
57
|
+
*/
|
|
58
|
+
const TRANSIENT_AVD_ENTRIES = new Set([
|
|
59
|
+
"snapshots",
|
|
60
|
+
"snapshot.lock.lock",
|
|
61
|
+
"snapshot.trace",
|
|
62
|
+
"read-snapshot.txt",
|
|
63
|
+
"multiinstance.lock",
|
|
64
|
+
"hardware-qemu.ini",
|
|
65
|
+
"hardware-qemu.ini.lock",
|
|
66
|
+
"userdata-qemu.img",
|
|
67
|
+
"userdata-qemu.img.qcow2",
|
|
68
|
+
"cache.img.qcow2",
|
|
69
|
+
"encryptionkey.img.qcow2",
|
|
70
|
+
"tmpAdbCmds",
|
|
71
|
+
"version_num.cache",
|
|
72
|
+
]);
|
|
73
|
+
/**
|
|
74
|
+
* Rewrite the `path=` / `path.rel=` lines of an AVD's pointer `.ini` to point at
|
|
75
|
+
* the clone's dir. Pure (tested).
|
|
76
|
+
*/
|
|
77
|
+
export function rewriteAvdPointerIni(text, newAvdDirAbs) {
|
|
78
|
+
const rel = `avd/${basename(newAvdDirAbs)}`;
|
|
79
|
+
return text
|
|
80
|
+
.replace(/^path=.*$/m, `path=${newAvdDirAbs}`)
|
|
81
|
+
.replace(/^path\.rel=.*$/m, `path.rel=${rel}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Clone an existing AVD by file-copy — NO avdmanager (and therefore no JDK
|
|
85
|
+
* dependency, which `avdmanager` needs and many machines lack). Copies the
|
|
86
|
+
* `.avd` dir minus its running-state, rewrites the pointer `.ini` paths and the
|
|
87
|
+
* `AvdId` / displayname. Turns "you need N AVDs" into "you need ONE".
|
|
88
|
+
*/
|
|
89
|
+
export function cloneAvd(source, newName) {
|
|
90
|
+
const home = avdHome();
|
|
91
|
+
const srcDir = join(home, `${source}.avd`);
|
|
92
|
+
const srcIni = join(home, `${source}.ini`);
|
|
93
|
+
const dstDir = join(home, `${newName}.avd`);
|
|
94
|
+
const dstIni = join(home, `${newName}.ini`);
|
|
95
|
+
if (!existsSync(srcDir)) {
|
|
96
|
+
throw new EmulatorError(`Cannot clone AVD "${source}" — ${srcDir} not found.`);
|
|
97
|
+
}
|
|
98
|
+
// Copy the data dir, skipping running-state/lock files (also avoids a needless
|
|
99
|
+
// multi-GB copy of the live userdata overlay).
|
|
100
|
+
cpSync(srcDir, dstDir, {
|
|
101
|
+
recursive: true,
|
|
102
|
+
filter: (src) => !TRANSIENT_AVD_ENTRIES.has(basename(src)),
|
|
103
|
+
});
|
|
104
|
+
// Pointer .ini (paths) and config.ini (AvdId / displayname) → the new name.
|
|
105
|
+
if (existsSync(srcIni)) {
|
|
106
|
+
writeFileSync(dstIni, rewriteAvdPointerIni(readFileSync(srcIni, "utf8"), dstDir));
|
|
107
|
+
}
|
|
108
|
+
const cfgPath = join(dstDir, "config.ini");
|
|
109
|
+
if (existsSync(cfgPath)) {
|
|
110
|
+
const cfg = readFileSync(cfgPath, "utf8")
|
|
111
|
+
.replace(/^AvdId=.*$/m, `AvdId=${newName}`)
|
|
112
|
+
.replace(/^avd\.ini\.displayname=.*$/m, `avd.ini.displayname=${newName}`);
|
|
113
|
+
writeFileSync(cfgPath, cfg);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Delete a (cloned) AVD's files. Best-effort, no avdmanager needed. */
|
|
117
|
+
export function deleteAvd(name) {
|
|
118
|
+
const home = avdHome();
|
|
119
|
+
rmSync(join(home, `${name}.avd`), { recursive: true, force: true });
|
|
120
|
+
rmSync(join(home, `${name}.ini`), { force: true });
|
|
121
|
+
}
|
|
122
|
+
/** AVD names available on this machine (`emulator -list-avds`). */
|
|
123
|
+
export async function listAvds() {
|
|
124
|
+
const bin = findEmulator();
|
|
125
|
+
if (!bin)
|
|
126
|
+
return [];
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await execFileAsync(bin, ["-list-avds"], { timeout: 15_000 });
|
|
129
|
+
return stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Launch an AVD as a tuned, lightweight, (by default) headless emulator on a
|
|
137
|
+
* specific console port. The serial is deterministically `emulator-<port>`.
|
|
138
|
+
* The flags keep it small so many fit on a normal machine: no window, software
|
|
139
|
+
* GPU, capped RAM, no boot animation / audio / snapshot writeback.
|
|
140
|
+
*/
|
|
141
|
+
export function spawnEmulator(avd, port, opts = {}) {
|
|
142
|
+
const bin = findEmulator();
|
|
143
|
+
if (!bin) {
|
|
144
|
+
throw new EmulatorError("emulator binary not found — set ISH_EMULATOR or install the Android SDK 'emulator' package.");
|
|
145
|
+
}
|
|
146
|
+
const args = [
|
|
147
|
+
"-avd", avd,
|
|
148
|
+
"-port", String(port),
|
|
149
|
+
"-no-snapshot-save",
|
|
150
|
+
"-no-boot-anim",
|
|
151
|
+
"-no-audio",
|
|
152
|
+
"-gpu", "swiftshader_indirect",
|
|
153
|
+
"-memory", String(opts.memMb ?? 1536),
|
|
154
|
+
];
|
|
155
|
+
if (opts.headless !== false)
|
|
156
|
+
args.push("-no-window");
|
|
157
|
+
// Detach so the emulator outlives this call; we track the child to kill it.
|
|
158
|
+
const child = spawn(bin, args, { detached: true, stdio: "ignore" });
|
|
159
|
+
child.unref();
|
|
160
|
+
return { child, serial: `emulator-${port}`, port };
|
|
161
|
+
}
|
|
162
|
+
/** Console ports for N emulators: 5554, 5556, 5558, … (adb wants even ports). */
|
|
163
|
+
export function emulatorPorts(count, base = 5554) {
|
|
164
|
+
return Array.from({ length: count }, (_, i) => base + i * 2);
|
|
165
|
+
}
|
|
166
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
167
|
+
/** Wait until `serial` is online AND `sys.boot_completed` is 1. */
|
|
168
|
+
export async function waitForBoot(serial, timeoutMs = 180_000) {
|
|
169
|
+
const deadline = Date.now() + timeoutMs;
|
|
170
|
+
while (Date.now() < deadline) {
|
|
171
|
+
try {
|
|
172
|
+
const booted = await withAdbSerial(serial, async () => {
|
|
173
|
+
await adb(["wait-for-device"], 10_000);
|
|
174
|
+
return (await adbShell(["getprop", "sys.boot_completed"], 10_000)).trim();
|
|
175
|
+
});
|
|
176
|
+
if (booted === "1")
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* not online yet */
|
|
181
|
+
}
|
|
182
|
+
await delay(2000);
|
|
183
|
+
}
|
|
184
|
+
throw new EmulatorError(`emulator ${serial} did not finish booting within ${timeoutMs / 1000}s`);
|
|
185
|
+
}
|
|
186
|
+
/** Gracefully stop an emulator (`adb -s <serial> emu kill`). Best-effort. */
|
|
187
|
+
export async function emuKill(serial) {
|
|
188
|
+
await withAdbSerial(serial, () => adb(["emu", "kill"], 15_000)).catch(() => { });
|
|
189
|
+
}
|