@houwert/conductor 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ exports.HELP = ` run-parallel --flows-dir <path> Run flows in parallel acro
21
21
  const child_process_1 = require("child_process");
22
22
  const path_1 = __importDefault(require("path"));
23
23
  const fs_1 = __importDefault(require("fs"));
24
+ const sdk_js_1 = require("../android/sdk.js");
24
25
  const output_js_1 = require("../output.js");
25
26
  async function runParallel(flowsDir, opts = {}) {
26
27
  if (!flowsDir) {
@@ -107,7 +108,7 @@ async function runShard(deviceId, flowFile) {
107
108
  async function discoverAllDevices() {
108
109
  const devices = [];
109
110
  try {
110
- const out = await spawnCapture('adb', ['devices']);
111
+ const out = await spawnCapture((0, sdk_js_1.resolveAndroidTool)('adb'), ['devices']);
111
112
  for (const line of out.split('\n').slice(1)) {
112
113
  const id = line.trim().split(/\s+/)[0];
113
114
  if (id && !line.includes('offline') && id !== '')
@@ -4,17 +4,25 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
+ exports.pickAndroidArch = pickAndroidArch;
8
+ exports.parseInstalledSystemImages = parseInstalledSystemImages;
9
+ exports.pickSystemImage = pickSystemImage;
10
+ exports.buildAvdmanagerCreateArgs = buildAvdmanagerCreateArgs;
7
11
  exports.startDevice = startDevice;
8
12
  exports.HELP = ` start-device
9
13
  --platform <ios|android|tvos|web> Boot a simulator/emulator, or start the web driver (Playwright)
10
14
  --os-version <n> iOS/tvOS version (e.g. 18) or Android API level (e.g. 33)
11
- --avd <name> Android AVD name (default: first available)
15
+ --avd <name> Android AVD name (default: first available; created if missing + --device-type)
12
16
  --name <name> Set a custom name on the device after creation (iOS/tvOS/web)
13
- --device-type <name> iOS/tvOS device type (e.g. "iPhone 16 Pro", "Apple TV 4K"); creates if needed
17
+ --device-type <name> iOS/tvOS device type (e.g. "iPhone 16 Pro", "Apple TV 4K") or
18
+ Android device profile (e.g. "pixel_7"); creates if needed
19
+ --system-image <id> Android only: override auto-picked system image
20
+ (e.g. "system-images;android-34;google_apis;arm64-v8a")
14
21
  --browser <chromium|firefox|webkit> Web only: which Playwright browser to launch (default: chromium)`;
15
22
  const fs_1 = __importDefault(require("fs"));
16
23
  const child_process_1 = require("child_process");
17
24
  const runner_js_1 = require("../runner.js");
25
+ const sdk_js_1 = require("../android/sdk.js");
18
26
  const client_js_1 = require("../daemon/client.js");
19
27
  const protocol_js_1 = require("../daemon/protocol.js");
20
28
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
@@ -389,8 +397,13 @@ async function startTvOS(osVersion, opts, name, deviceType) {
389
397
  return 0;
390
398
  }
391
399
  // ── Android ───────────────────────────────────────────────────────────────────
400
+ // TODO: Replace bare command names with the resolver from `android-sdk-path-resolver-68477d`
401
+ // once it lands on main. For now we rely on `emulator`/`adb`/`avdmanager`/`sdkmanager`
402
+ // being on PATH.
392
403
  async function listAVDs() {
393
- const result = await (0, runner_js_1.spawnCommand)('emulator', ['-list-avds']);
404
+ const result = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('emulator'), ['-list-avds'], {
405
+ env: (0, sdk_js_1.androidSpawnEnv)(),
406
+ });
394
407
  if (!result.success)
395
408
  throw new Error(`emulator -list-avds failed: ${result.stderr}`);
396
409
  return result.stdout
@@ -398,11 +411,146 @@ async function listAVDs() {
398
411
  .map((l) => l.trim())
399
412
  .filter(Boolean);
400
413
  }
414
+ /** Pick the Android system-image arch tag based on the host CPU. */
415
+ function pickAndroidArch(arch = process.arch) {
416
+ return arch === 'arm64' ? 'arm64-v8a' : 'x86_64';
417
+ }
418
+ /**
419
+ * Parse `sdkmanager --list_installed` (or --list) output, returning installed
420
+ * system-image package paths like "system-images;android-34;google_apis;arm64-v8a".
421
+ */
422
+ function parseInstalledSystemImages(stdout) {
423
+ const images = [];
424
+ for (const rawLine of stdout.split('\n')) {
425
+ const line = rawLine.trim();
426
+ if (!line.startsWith('system-images;'))
427
+ continue;
428
+ // Format is "<path> | <version> | <description> | <location>" with leading whitespace
429
+ const path = line.split(/[|\s]+/)[0];
430
+ if (path && !images.includes(path))
431
+ images.push(path);
432
+ }
433
+ return images;
434
+ }
435
+ /**
436
+ * Pick the best installed system image matching the requested API level and arch.
437
+ * Prefers `google_apis` over `default` (no Play store dependency on Apple Silicon
438
+ * where Play images often lack arm64). Returns undefined if no match.
439
+ */
440
+ function pickSystemImage(installed, apiLevel, arch) {
441
+ const filtered = installed.filter((img) => {
442
+ const parts = img.split(';');
443
+ // parts: ["system-images", "android-<api>", "<variant>", "<arch>"]
444
+ if (parts.length < 4)
445
+ return false;
446
+ if (parts[3] !== arch)
447
+ return false;
448
+ if (apiLevel && parts[1] !== `android-${apiLevel}`)
449
+ return false;
450
+ return true;
451
+ });
452
+ if (filtered.length === 0)
453
+ return undefined;
454
+ // Variant preference: google_apis > google_apis_playstore > default > others
455
+ const rank = (img) => {
456
+ const variant = img.split(';')[2];
457
+ if (variant === 'google_apis')
458
+ return 0;
459
+ if (variant === 'google_apis_playstore')
460
+ return 1;
461
+ if (variant === 'default')
462
+ return 2;
463
+ return 3;
464
+ };
465
+ filtered.sort((a, b) => {
466
+ const r = rank(a) - rank(b);
467
+ if (r !== 0)
468
+ return r;
469
+ // Then prefer higher API level
470
+ const ai = parseInt(a.split(';')[1].replace('android-', ''), 10) || 0;
471
+ const bi = parseInt(b.split(';')[1].replace('android-', ''), 10) || 0;
472
+ return bi - ai;
473
+ });
474
+ return filtered[0];
475
+ }
476
+ /** Build the avdmanager argv for AVD creation — pure, exported for tests. */
477
+ function buildAvdmanagerCreateArgs(avdName, systemImage, deviceProfile) {
478
+ return ['create', 'avd', '-n', avdName, '-k', systemImage, '-d', deviceProfile];
479
+ }
480
+ async function listInstalledSystemImages() {
481
+ const result = await (0, runner_js_1.spawnCommand)('sdkmanager', ['--list_installed']);
482
+ if (!result.success) {
483
+ throw new Error(`sdkmanager --list_installed failed: ${result.stderr.trim() || result.stdout.trim()}`);
484
+ }
485
+ return parseInstalledSystemImages(result.stdout);
486
+ }
487
+ /** Run avdmanager create avd, piping "no\n" so it skips the custom hardware prompt. */
488
+ async function spawnAvdmanagerCreate(args) {
489
+ await new Promise((resolve, reject) => {
490
+ const proc = (0, child_process_1.spawn)('avdmanager', args, { stdio: ['pipe', 'pipe', 'pipe'] });
491
+ let stderr = '';
492
+ proc.stdout.on('data', () => {
493
+ /* discard */
494
+ });
495
+ proc.stderr.on('data', (chunk) => {
496
+ stderr += chunk.toString();
497
+ });
498
+ proc.on('error', (err) => reject(err));
499
+ proc.on('close', (code) => {
500
+ if (code === 0)
501
+ resolve();
502
+ else
503
+ reject(new Error(`avdmanager exited ${code}: ${stderr.trim()}`));
504
+ });
505
+ proc.stdin.end('no\n');
506
+ });
507
+ }
508
+ async function createAndroidAVD(avdName, deviceProfile, apiLevel, systemImageOverride) {
509
+ const arch = pickAndroidArch();
510
+ let systemImage;
511
+ if (systemImageOverride) {
512
+ systemImage = systemImageOverride;
513
+ }
514
+ else {
515
+ let installed;
516
+ try {
517
+ installed = await listInstalledSystemImages();
518
+ }
519
+ catch (e) {
520
+ const msg = e instanceof Error ? e.message : String(e);
521
+ throw new Error(`Could not list installed Android system images (${msg}). ` +
522
+ `Ensure the Android SDK command-line tools are installed and on PATH.`);
523
+ }
524
+ const picked = pickSystemImage(installed, apiLevel, arch);
525
+ if (!picked) {
526
+ const apiHint = apiLevel ?? '34';
527
+ throw new Error(`No installed Android system image matches arch=${arch}` +
528
+ (apiLevel ? `, api=${apiLevel}` : '') +
529
+ `.\nInstall one with:\n sdkmanager "system-images;android-${apiHint};google_apis;${arch}"`);
530
+ }
531
+ systemImage = picked;
532
+ }
533
+ const args = buildAvdmanagerCreateArgs(avdName, systemImage, deviceProfile);
534
+ try {
535
+ await spawnAvdmanagerCreate(args);
536
+ }
537
+ catch (e) {
538
+ const msg = e instanceof Error ? e.message : String(e);
539
+ throw new Error(`Failed to create AVD "${avdName}": ${msg}`);
540
+ }
541
+ // Validate the AVD now appears in emulator -list-avds
542
+ const avds = await listAVDs();
543
+ if (!avds.includes(avdName)) {
544
+ throw new Error(`AVD "${avdName}" was not registered after creation (not in emulator -list-avds).`);
545
+ }
546
+ }
401
547
  async function waitForAndroidBoot(avdName) {
402
548
  const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS;
403
549
  const connectedBefore = new Set();
404
550
  // Snapshot currently connected devices so we can identify the new one
405
- const before = await (0, runner_js_1.spawnCommand)('adb', ['devices']);
551
+ const before = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['devices'], {
552
+ env: (0, sdk_js_1.androidSpawnEnv)(),
553
+ });
406
554
  for (const line of before.stdout.split('\n').slice(1)) {
407
555
  const id = line.trim().split(/\s+/)[0];
408
556
  if (id)
@@ -410,7 +558,9 @@ async function waitForAndroidBoot(avdName) {
410
558
  }
411
559
  while (Date.now() < deadline) {
412
560
  await (0, utils_js_1.sleep)(POLL_MS);
413
- const result = await (0, runner_js_1.spawnCommand)('adb', ['devices']);
561
+ const result = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['devices'], {
562
+ env: (0, sdk_js_1.androidSpawnEnv)(),
563
+ });
414
564
  if (!result.success)
415
565
  continue;
416
566
  for (const line of result.stdout.split('\n').slice(1)) {
@@ -419,13 +569,7 @@ async function waitForAndroidBoot(avdName) {
419
569
  const status = parts[1];
420
570
  if (id && status === 'device' && !connectedBefore.has(id)) {
421
571
  // Check boot completed
422
- const boot = await (0, runner_js_1.spawnCommand)('adb', [
423
- '-s',
424
- id,
425
- 'shell',
426
- 'getprop',
427
- 'sys.boot_completed',
428
- ]);
572
+ const boot = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', id, 'shell', 'getprop', 'sys.boot_completed'], { env: (0, sdk_js_1.androidSpawnEnv)() });
429
573
  if (boot.stdout.trim() === '1')
430
574
  return id;
431
575
  }
@@ -433,7 +577,7 @@ async function waitForAndroidBoot(avdName) {
433
577
  }
434
578
  throw new Error(`Android emulator (${avdName}) did not appear within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s`);
435
579
  }
436
- async function startAndroid(avdName, opts) {
580
+ async function startAndroid(avdName, opts, deviceType, osVersion, systemImage) {
437
581
  let avds;
438
582
  try {
439
583
  avds = await listAVDs();
@@ -442,20 +586,38 @@ async function startAndroid(avdName, opts) {
442
586
  (0, output_js_1.printError)(`Failed to list AVDs: ${e instanceof Error ? e.message : String(e)}`, opts);
443
587
  return 1;
444
588
  }
445
- if (avds.length === 0) {
446
- (0, output_js_1.printError)('No Android AVDs found. Create one in Android Studio Device Manager.', opts);
589
+ let target = avdName ?? avds[0];
590
+ // If the requested AVD doesn't exist (or none exist) and a device profile was
591
+ // provided, create the AVD before booting.
592
+ const needsCreate = (avds.length === 0 || (avdName !== undefined && !avds.includes(avdName))) &&
593
+ deviceType !== undefined;
594
+ if (needsCreate) {
595
+ if (!avdName) {
596
+ (0, output_js_1.printError)('--avd <name> is required when creating an AVD with --device-type.', opts);
597
+ return 1;
598
+ }
599
+ console.log(`No AVD "${avdName}" found. Creating one with device profile "${deviceType}"...`);
600
+ try {
601
+ await createAndroidAVD(avdName, deviceType, osVersion, systemImage);
602
+ }
603
+ catch (e) {
604
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
605
+ return 1;
606
+ }
607
+ target = avdName;
608
+ }
609
+ else if (avds.length === 0) {
610
+ (0, output_js_1.printError)('No Android AVDs found. Pass --device-type <profile> --avd <name> to create one, ' +
611
+ 'or create one in Android Studio → Device Manager.', opts);
447
612
  return 1;
448
613
  }
449
- const target = avdName ?? avds[0];
450
- if (!avds.includes(target)) {
451
- (0, output_js_1.printError)(`AVD "${target}" not found. Available: ${avds.join(', ')}`, opts);
614
+ else if (!avds.includes(target)) {
615
+ (0, output_js_1.printError)(`AVD "${target}" not found. Available: ${avds.join(', ')}. ` +
616
+ `Pass --device-type to create it.`, opts);
452
617
  return 1;
453
618
  }
454
619
  console.log(`Launching emulator: ${target}...`);
455
- const proc = (0, child_process_1.spawn)('emulator', ['-avd', target, '-netdelay', 'none', '-netspeed', 'full'], {
456
- detached: true,
457
- stdio: 'ignore',
458
- });
620
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('emulator'), ['-avd', target, '-netdelay', 'none', '-netspeed', 'full'], { detached: true, stdio: 'ignore', env: (0, sdk_js_1.androidSpawnEnv)() });
459
621
  proc.unref();
460
622
  let deviceId;
461
623
  try {
@@ -526,7 +688,7 @@ async function startDevice(platform, opts, flags) {
526
688
  case 'tvos':
527
689
  return startTvOS(flags.osVersion, opts, flags.name, flags.deviceType);
528
690
  case 'android':
529
- return startAndroid(flags.avd, opts);
691
+ return startAndroid(flags.avd, opts, flags.deviceType, flags.osVersion, flags.systemImage);
530
692
  case 'web':
531
693
  return startWebDriver(opts, flags.browser, flags.name);
532
694
  default:
@@ -6,6 +6,7 @@ exports.HELP = ` stop-device [<name-or-id>]
6
6
  --platform <ios|tvos|android|web> Scope to a single platform
7
7
  --all Stop all booted simulators / running emulators / web sessions`;
8
8
  const runner_js_1 = require("../runner.js");
9
+ const sdk_js_1 = require("../android/sdk.js");
9
10
  const output_js_1 = require("../output.js");
10
11
  const client_js_1 = require("../daemon/client.js");
11
12
  const list_devices_js_1 = require("./list-devices.js");
@@ -18,7 +19,7 @@ async function shutdownSimulator(udid) {
18
19
  }
19
20
  // ── Android ──────────────────────────────────────────────────────────────────
20
21
  async function killEmulator(serial) {
21
- const result = await (0, runner_js_1.spawnCommand)('adb', ['-s', serial, 'emu', 'kill']);
22
+ const result = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', serial, 'emu', 'kill']);
22
23
  if (!result.success) {
23
24
  throw new Error(`Failed to kill emulator ${serial}: ${result.stderr.trim()}`);
24
25
  }
@@ -19,6 +19,7 @@ const url_1 = __importDefault(require("url"));
19
19
  const fs_1 = __importDefault(require("fs"));
20
20
  const path_1 = __importDefault(require("path"));
21
21
  const protocol_js_1 = require("./protocol.js");
22
+ const sdk_js_1 = require("../android/sdk.js");
22
23
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
23
24
  const android_js_1 = require("../drivers/android.js");
24
25
  const web_server_js_1 = require("./web-server.js");
@@ -129,6 +130,7 @@ async function ensureDriverRunning() {
129
130
  }
130
131
  // ── Daemon main ──────────────────────────────────────────────────────────────
131
132
  async function main() {
133
+ (0, sdk_js_1.ensureAndroidEnv)();
132
134
  // Ensure per-session daemon directory exists
133
135
  fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
134
136
  fs_1.default.writeFileSync(PID_FILE, String(process.pid));
@@ -868,6 +868,44 @@ async function handleRequest(req, res, dlog) {
868
868
  }
869
869
  return;
870
870
  }
871
+ case '/heapSnapshot': {
872
+ // V8 heap snapshot via CDP. The snapshot streams as JSON chunks via
873
+ // `HeapProfiler.addHeapSnapshotChunk` events; we concatenate them and
874
+ // return the assembled JSON. Snapshots are large (10-100+ MB on real
875
+ // pages) — fine over localhost HTTP.
876
+ const p = await getPage(dlog);
877
+ const session = await p.context().newCDPSession(p);
878
+ const chunks = [];
879
+ const onChunk = (e) => {
880
+ chunks.push(e.chunk);
881
+ };
882
+ try {
883
+ await session.send('HeapProfiler.enable').catch(() => { });
884
+ // Optional: collect garbage before snapshotting so transient
885
+ // allocations don't muddy diff comparisons. Triggered via ?gc=1.
886
+ if (parsedUrl.query['gc']) {
887
+ await session.send('HeapProfiler.collectGarbage').catch(() => { });
888
+ }
889
+ session.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
890
+ await session.send('HeapProfiler.takeHeapSnapshot', {
891
+ reportProgress: false,
892
+ captureNumericValue: false,
893
+ exposeInternals: false,
894
+ });
895
+ // Each chunk is already JSON text; concatenation yields the full snapshot JSON.
896
+ const body = chunks.join('');
897
+ res.writeHead(200, {
898
+ 'Content-Type': 'application/json',
899
+ 'Content-Length': Buffer.byteLength(body),
900
+ });
901
+ res.end(body);
902
+ }
903
+ finally {
904
+ session.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
905
+ await session.detach().catch(() => { });
906
+ }
907
+ return;
908
+ }
871
909
  case '/consoleLogs': {
872
910
  const since = parsedUrl.query['since'] ?? '';
873
911
  const entries = since
@@ -50,6 +50,7 @@ const protoLoader = __importStar(require("@grpc/proto-loader"));
50
50
  const child_process_1 = require("child_process");
51
51
  const fs_1 = __importDefault(require("fs"));
52
52
  const path_1 = __importDefault(require("path"));
53
+ const sdk_js_1 = require("../android/sdk.js");
53
54
  // __dirname is available in CommonJS — points to dist/drivers/
54
55
  const PROTO_PATH = path_1.default.join(__dirname, '../../proto/conductor_android.proto');
55
56
  let _packageDef = null;
@@ -148,7 +149,9 @@ class AndroidDriver {
148
149
  // ── ADB-shell operations (not in gRPC proto) ─────────────────────────────
149
150
  adb(args) {
150
151
  return new Promise((resolve, reject) => {
151
- const proc = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, ...args], { stdio: 'ignore' });
152
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', this.deviceId, ...args], {
153
+ stdio: 'ignore',
154
+ });
152
155
  proc.on('close', (code) => {
153
156
  if (code === 0)
154
157
  resolve();
@@ -182,7 +185,7 @@ class AndroidDriver {
182
185
  }
183
186
  adbOutput(args) {
184
187
  return new Promise((resolve, reject) => {
185
- const proc = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, ...args], {
188
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', this.deviceId, ...args], {
186
189
  stdio: ['ignore', 'pipe', 'pipe'],
187
190
  });
188
191
  let stdout = '';
@@ -200,7 +203,9 @@ class AndroidDriver {
200
203
  }
201
204
  async getForegroundApp() {
202
205
  const output = await this.adbOutput(['shell', 'dumpsys', 'activity', 'activities']);
203
- const match = output.match(/mResumedActivity.*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
206
+ // API 28- prints `mResumedActivity:`; API 29+ uses `ResumedActivity:` /
207
+ // `topResumedActivity=`. Accept all three so this works across versions.
208
+ const match = output.match(/(?:m|top)?ResumedActivity[=:].*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
204
209
  if (!match)
205
210
  throw new Error('Could not determine foreground app');
206
211
  return match[1];
@@ -326,7 +331,7 @@ class AndroidDriver {
326
331
  if (this._recordingProcess)
327
332
  await this.stopRecording();
328
333
  this._recordingOutputPath = outputPath;
329
- this._recordingProcess = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, 'shell', 'screenrecord', '/sdcard/conductor_recording.mp4'], { stdio: 'ignore' });
334
+ this._recordingProcess = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', this.deviceId, 'shell', 'screenrecord', '/sdcard/conductor_recording.mp4'], { stdio: 'ignore' });
330
335
  }
331
336
  async stopRecording() {
332
337
  if (this._recordingProcess) {
@@ -42,6 +42,7 @@ const fs_1 = __importDefault(require("fs"));
42
42
  const path_1 = __importDefault(require("path"));
43
43
  const verbose_js_1 = require("../verbose.js");
44
44
  const utils_js_1 = require("../utils.js");
45
+ const sdk_js_1 = require("../android/sdk.js");
45
46
  /** Cache: deviceId → platform */
46
47
  const _platformCache = new Map();
47
48
  async function detectPlatform(deviceId) {
@@ -353,8 +354,24 @@ async function installDriver(deviceId) {
353
354
  throw new Error(`Conductor driver APKs not found at ${path_1.default.join(driversDir, 'android')}.\n` +
354
355
  `Run 'make package-cli' from the repo root to build and bundle the drivers.`);
355
356
  }
356
- await spawnAndWait('adb', ['-s', deviceId, 'install', '-r', '-t', '-g', appApk]);
357
- await spawnAndWait('adb', ['-s', deviceId, 'install', '-r', '-t', '-g', serverApk]);
357
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
358
+ '-s',
359
+ deviceId,
360
+ 'install',
361
+ '-r',
362
+ '-t',
363
+ '-g',
364
+ appApk,
365
+ ]);
366
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
367
+ '-s',
368
+ deviceId,
369
+ 'install',
370
+ '-r',
371
+ '-t',
372
+ '-g',
373
+ serverApk,
374
+ ]);
358
375
  (0, verbose_js_1.log)(`installDriver: done`);
359
376
  }
360
377
  // ── iOS bootstrap ─────────────────────────────────────────────────────────────
@@ -598,9 +615,15 @@ async function startAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
598
615
  }
599
616
  (0, verbose_js_1.log)(`Starting Android driver for device ${deviceId} on port ${port}`);
600
617
  // Step 1: ADB port forward
601
- await spawnAndWait('adb', ['-s', deviceId, 'forward', `tcp:${port}`, `tcp:${port}`]);
618
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
619
+ '-s',
620
+ deviceId,
621
+ 'forward',
622
+ `tcp:${port}`,
623
+ `tcp:${port}`,
624
+ ]);
602
625
  // Step 2: Get device API level to decide instrumentation flags
603
- const apiResult = await spawnCapture('adb', [
626
+ const apiResult = await spawnCapture((0, sdk_js_1.resolveAndroidTool)('adb'), [
604
627
  '-s',
605
628
  deviceId,
606
629
  'shell',
@@ -628,7 +651,7 @@ async function startAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
628
651
  String(port),
629
652
  CONDUCTOR_TEST_RUNNER,
630
653
  ];
631
- const proc = (0, child_process_1.spawn)('adb', instrArgs, {
654
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), instrArgs, {
632
655
  detached: true,
633
656
  stdio: ['ignore', 'ignore', 'ignore'],
634
657
  });
@@ -647,7 +670,7 @@ async function startAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
647
670
  `Try running: conductor install --device ${deviceId}`);
648
671
  }
649
672
  async function stopAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
650
- await spawnAndWait('adb', [
673
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
651
674
  '-s',
652
675
  deviceId,
653
676
  'shell',
@@ -655,7 +678,13 @@ async function stopAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
655
678
  'force-stop',
656
679
  'dev.houwert.conductor',
657
680
  ]).catch(() => { });
658
- await spawnAndWait('adb', ['-s', deviceId, 'forward', '--remove', `tcp:${port}`]).catch(() => { });
681
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
682
+ '-s',
683
+ deviceId,
684
+ 'forward',
685
+ '--remove',
686
+ `tcp:${port}`,
687
+ ]).catch(() => { });
659
688
  }
660
689
  // ── Web bootstrap ────────────────────────────────────────────────────────────
661
690
  /**
@@ -783,8 +812,18 @@ async function uninstallDriver(deviceId, platform) {
783
812
  await spawnAndWait('xcrun', ['simctl', 'uninstall', deviceId, TVOS_RUNNER_BUNDLE_ID]).catch(() => { });
784
813
  }
785
814
  else {
786
- await spawnAndWait('adb', ['-s', deviceId, 'uninstall', 'dev.houwert.conductor']).catch(() => { });
787
- await spawnAndWait('adb', ['-s', deviceId, 'uninstall', 'dev.houwert.conductor.test']).catch(() => { });
815
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
816
+ '-s',
817
+ deviceId,
818
+ 'uninstall',
819
+ 'dev.houwert.conductor',
820
+ ]).catch(() => { });
821
+ await spawnAndWait((0, sdk_js_1.resolveAndroidTool)('adb'), [
822
+ '-s',
823
+ deviceId,
824
+ 'uninstall',
825
+ 'dev.houwert.conductor.test',
826
+ ]).catch(() => { });
788
827
  }
789
828
  }
790
829
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@ exports.AndroidLogSource = void 0;
5
5
  * Android log source — streams logs from an Android device/emulator via `adb logcat`.
6
6
  */
7
7
  const child_process_1 = require("child_process");
8
+ const sdk_js_1 = require("../../android/sdk.js");
8
9
  function mapPriority(priority) {
9
10
  // Android log priorities: 2=V, 3=D, 4=I, 5=W, 6=E, 7=F
10
11
  const p = typeof priority === 'string' ? priority.toUpperCase() : String(priority);
@@ -44,10 +45,7 @@ class AndroidLogSource {
44
45
  let pid;
45
46
  if (this.appId) {
46
47
  try {
47
- pid = (0, child_process_1.execSync)(`adb -s ${this.deviceId} shell pidof ${this.appId}`, {
48
- encoding: 'utf-8',
49
- timeout: 5000,
50
- }).trim();
48
+ pid = (0, child_process_1.execFileSync)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', this.deviceId, 'shell', 'pidof', this.appId], { encoding: 'utf-8', timeout: 5000 }).trim();
51
49
  }
52
50
  catch {
53
51
  // App may not be running yet — proceed without PID filter
@@ -55,7 +53,9 @@ class AndroidLogSource {
55
53
  }
56
54
  // Clear existing logcat buffer so we start fresh
57
55
  try {
58
- (0, child_process_1.execSync)(`adb -s ${this.deviceId} logcat -c`, { timeout: 5000 });
56
+ (0, child_process_1.execFileSync)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', this.deviceId, 'logcat', '-c'], {
57
+ timeout: 5000,
58
+ });
59
59
  }
60
60
  catch {
61
61
  // Ignore clear failures
@@ -71,7 +71,7 @@ class AndroidLogSource {
71
71
  if (pid) {
72
72
  args.push('--pid', pid);
73
73
  }
74
- this.proc = (0, child_process_1.spawn)('adb', args, { stdio: ['ignore', 'pipe', 'pipe'] });
74
+ this.proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), args, { stdio: ['ignore', 'pipe', 'pipe'] });
75
75
  let stderrChunks = '';
76
76
  this.proc.stderr.on('data', (chunk) => {
77
77
  stderrChunks += chunk.toString('utf-8');
@@ -24,6 +24,7 @@ exports.targetsForDevice = targetsForDevice;
24
24
  */
25
25
  const child_process_1 = require("child_process");
26
26
  const metro_js_1 = require("./metro.js");
27
+ const sdk_js_1 = require("../../android/sdk.js");
27
28
  /** Metro dev-server port ranges we consider. */
28
29
  const METRO_PORT_RANGES = [
29
30
  [8080, 8099], // Metro default range
@@ -58,7 +59,12 @@ async function discoverMetroPortForDevice(platform, deviceId) {
58
59
  }
59
60
  async function discoverMetroPortAndroid(deviceId) {
60
61
  try {
61
- const output = await spawnCapture('adb', ['-s', deviceId, 'reverse', '--list']);
62
+ const output = await spawnCapture((0, sdk_js_1.resolveAndroidTool)('adb'), [
63
+ '-s',
64
+ deviceId,
65
+ 'reverse',
66
+ '--list',
67
+ ]);
62
68
  // Lines look like: host-13 tcp:8082 tcp:8082
63
69
  for (const line of output.split('\n')) {
64
70
  const match = line.match(/tcp:(\d+)\s+tcp:(\d+)/);
@@ -172,7 +178,7 @@ async function getDeviceDisplayName(platform, deviceId) {
172
178
  }
173
179
  if (platform === 'android') {
174
180
  try {
175
- const output = await spawnCapture('adb', [
181
+ const output = await spawnCapture((0, sdk_js_1.resolveAndroidTool)('adb'), [
176
182
  '-s',
177
183
  deviceId,
178
184
  'shell',
@@ -18,7 +18,7 @@ class WebDriver {
18
18
  this.host = host;
19
19
  this.deviceId = deviceId;
20
20
  }
21
- request(method, path, body) {
21
+ request(method, path, body, timeoutMs = 30000) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const bodyBuf = body !== undefined ? Buffer.from(JSON.stringify(body), 'utf-8') : undefined;
24
24
  const options = {
@@ -38,7 +38,7 @@ class WebDriver {
38
38
  res.on('end', () => resolve({ status: res.statusCode ?? 0, data: Buffer.concat(chunks) }));
39
39
  res.on('error', reject);
40
40
  });
41
- req.setTimeout(30000, () => {
41
+ req.setTimeout(timeoutMs, () => {
42
42
  req.destroy(new Error(`Web driver request timed out: ${method} ${path}`));
43
43
  });
44
44
  req.on('error', reject);
@@ -139,6 +139,20 @@ class WebDriver {
139
139
  async memory() {
140
140
  return this.get('memory');
141
141
  }
142
+ /**
143
+ * Take a V8 heap snapshot via CDP. Returns the raw JSON text (large; tens
144
+ * of MB on a real page). Caller can write it to a `.heapsnapshot` file for
145
+ * Chrome DevTools and/or parse it for class statistics.
146
+ */
147
+ async heapSnapshot(opts = {}) {
148
+ // 5-minute timeout — large pages can take 30-60s to snapshot + transfer.
149
+ const qs = opts.gc ? '?gc=1' : '';
150
+ const { status, data } = await this.request('GET', `/heapSnapshot${qs}`, undefined, 5 * 60000);
151
+ if (status < 200 || status >= 300) {
152
+ throw new Error(`Web driver heapSnapshot failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
153
+ }
154
+ return data.toString('utf-8');
155
+ }
142
156
  async eraseAllText(count = 50) {
143
157
  await this.post('eraseText', { count });
144
158
  }