@houwert/conductor 0.14.0 → 0.16.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.
@@ -37,12 +37,29 @@ async function resolveDeviceId(sessionName) {
37
37
  const session = await (0, session_js_1.getSession)(sessionName);
38
38
  return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
39
39
  }
40
- async function resolveAppId(explicit, sessionName, deviceId) {
40
+ async function resolveAppId(explicit, sessionName, deviceId, platform) {
41
41
  if (explicit)
42
42
  return explicit;
43
43
  // No arg: always resolve from the live foreground app, not the session file.
44
44
  // The session's appId reflects the last `launch-app` call, which can be stale
45
45
  // if the user switched apps on the device by other means.
46
+ //
47
+ // On Android, foreground-app detection only needs `adb shell dumpsys` — going
48
+ // through getDriver() would force the gRPC daemon to start (and require the
49
+ // driver APK to be installed), which is wasteful for a read-only memory dump.
50
+ if (platform === 'android') {
51
+ const dump = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities'], { env: (0, sdk_js_1.androidSpawnEnv)() });
52
+ if (dump.success) {
53
+ // Different Android versions print this differently:
54
+ // API 28-: mResumedActivity: ActivityRecord{... pkg/.Activity ...}
55
+ // API 29+: topResumedActivity=ActivityRecord{... pkg/.Activity ...}
56
+ // ResumedActivity: ActivityRecord{... pkg/.Activity ...}
57
+ const m = dump.stdout.match(/(?:m|top)?ResumedActivity[=:].*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
58
+ if (m)
59
+ return m[1];
60
+ }
61
+ return undefined;
62
+ }
46
63
  try {
47
64
  const driver = await (0, runner_js_1.getDriver)(sessionName);
48
65
  if (driver instanceof android_js_1.AndroidDriver)
@@ -1104,13 +1121,17 @@ async function memory(appIdArg, opts = {}, sessionName = 'default', memOpts = {}
1104
1121
  report = await collectWeb(deviceId, sessionName, memOpts);
1105
1122
  }
1106
1123
  else {
1107
- const appId = await resolveAppId(appIdArg, sessionName, deviceId);
1124
+ const appId = await resolveAppId(appIdArg, sessionName, deviceId, platform);
1108
1125
  if (platform === 'android') {
1109
1126
  report = await collectAndroid(deviceId, appId, memOpts);
1110
1127
  }
1111
1128
  else {
1112
1129
  report = await collectIOS(deviceId, platform, appId, memOpts);
1113
1130
  }
1131
+ if (!appId) {
1132
+ report.notes ?? (report.notes = []);
1133
+ report.notes.push('No foreground app detected — pass an app id (e.g. `conductor memory com.example.app --all`) to get per-app memory, objects, and leaks.');
1134
+ }
1114
1135
  }
1115
1136
  report.capturedAt = new Date().toISOString();
1116
1137
  // Diff current report against a saved snapshot.
@@ -5,19 +5,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
7
  exports.screenshot = screenshot;
8
- exports.HELP = ` take-screenshot [--output <path>] Take screenshot`;
8
+ exports.HELP = ` take-screenshot [--output <path>] [--full-page]
9
+ Take screenshot (--full-page: web only, capture entire scrollable page)`;
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const promises_1 = __importDefault(require("fs/promises"));
11
12
  const runner_js_1 = require("../runner.js");
12
13
  const output_js_1 = require("../output.js");
13
- async function screenshot(outputPath, opts = {}, sessionName = 'default') {
14
+ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false) {
14
15
  const timestamp = Date.now();
15
16
  const defaultName = `screenshot-${timestamp}.png`;
16
17
  const resolvedPath = outputPath
17
18
  ? path_1.default.resolve(outputPath)
18
19
  : path_1.default.resolve(process.cwd(), defaultName);
19
20
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
20
- const buf = await driver.screenshot();
21
+ const buf = await driver.screenshot({ fullPage });
21
22
  await promises_1.default.writeFile(resolvedPath, buf);
22
23
  }, sessionName);
23
24
  if (result.success) {
@@ -4,13 +4,20 @@ 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");
@@ -390,6 +397,9 @@ async function startTvOS(osVersion, opts, name, deviceType) {
390
397
  return 0;
391
398
  }
392
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.
393
403
  async function listAVDs() {
394
404
  const result = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('emulator'), ['-list-avds'], {
395
405
  env: (0, sdk_js_1.androidSpawnEnv)(),
@@ -401,6 +411,139 @@ async function listAVDs() {
401
411
  .map((l) => l.trim())
402
412
  .filter(Boolean);
403
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
+ }
404
547
  async function waitForAndroidBoot(avdName) {
405
548
  const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS;
406
549
  const connectedBefore = new Set();
@@ -434,7 +577,7 @@ async function waitForAndroidBoot(avdName) {
434
577
  }
435
578
  throw new Error(`Android emulator (${avdName}) did not appear within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s`);
436
579
  }
437
- async function startAndroid(avdName, opts) {
580
+ async function startAndroid(avdName, opts, deviceType, osVersion, systemImage) {
438
581
  let avds;
439
582
  try {
440
583
  avds = await listAVDs();
@@ -443,13 +586,34 @@ async function startAndroid(avdName, opts) {
443
586
  (0, output_js_1.printError)(`Failed to list AVDs: ${e instanceof Error ? e.message : String(e)}`, opts);
444
587
  return 1;
445
588
  }
446
- if (avds.length === 0) {
447
- (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);
448
612
  return 1;
449
613
  }
450
- const target = avdName ?? avds[0];
451
- if (!avds.includes(target)) {
452
- (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);
453
617
  return 1;
454
618
  }
455
619
  console.log(`Launching emulator: ${target}...`);
@@ -524,7 +688,7 @@ async function startDevice(platform, opts, flags) {
524
688
  case 'tvos':
525
689
  return startTvOS(flags.osVersion, opts, flags.name, flags.deviceType);
526
690
  case 'android':
527
- return startAndroid(flags.avd, opts);
691
+ return startAndroid(flags.avd, opts, flags.deviceType, flags.osVersion, flags.systemImage);
528
692
  case 'web':
529
693
  return startWebDriver(opts, flags.browser, flags.name);
530
694
  default:
@@ -437,6 +437,24 @@ let _server = null;
437
437
  let _cdpMode = false;
438
438
  let _cdpPort = 0;
439
439
  let _pageTargetId = null;
440
+ /**
441
+ * Default User-Agent applied to created contexts. Derived from the launched
442
+ * browser's UA with "HeadlessChrome" rewritten to "Chrome" so remote servers
443
+ * don't see automation/headless markers in the UA string.
444
+ */
445
+ let _defaultUserAgent;
446
+ async function deriveDefaultUserAgent(browser) {
447
+ try {
448
+ const tmpCtx = await browser.newContext();
449
+ const tmpPage = await tmpCtx.newPage();
450
+ const ua = await tmpPage.evaluate(() => globalThis.navigator.userAgent);
451
+ await tmpCtx.close().catch(() => { });
452
+ return ua.replace(/HeadlessChrome/g, 'Chrome');
453
+ }
454
+ catch {
455
+ return undefined;
456
+ }
457
+ }
440
458
  /** Find a free TCP port by briefly binding to port 0. */
441
459
  function findFreePort() {
442
460
  return new Promise((resolve, reject) => {
@@ -557,8 +575,10 @@ async function startWebServer(port, browserName = 'chromium', dlog = () => { },
557
575
  ? [`--remote-debugging-port=${_cdpPort}`, '--disable-search-engine-choice-screen']
558
576
  : undefined,
559
577
  });
578
+ _defaultUserAgent = await deriveDefaultUserAgent(_browser);
560
579
  _context = await _browser.newContext({
561
580
  viewport: DEFAULT_VIEWPORT,
581
+ userAgent: _defaultUserAgent,
562
582
  });
563
583
  _page = await _context.newPage();
564
584
  attachConsoleListeners(_page);
@@ -692,6 +712,7 @@ async function recreateBrowserContext(dlog) {
692
712
  _page = null;
693
713
  _context = await _browser.newContext({
694
714
  viewport: DEFAULT_VIEWPORT,
715
+ userAgent: _defaultUserAgent,
695
716
  });
696
717
  _page = await _context.newPage();
697
718
  attachConsoleListeners(_page);
@@ -791,7 +812,9 @@ async function handleRequest(req, res, dlog) {
791
812
  return;
792
813
  }
793
814
  case '/screenshot': {
794
- const buf = await (await getPage(dlog)).screenshot({ type: 'png' });
815
+ const fullPageRaw = parsedUrl.query.fullPage;
816
+ const fullPage = fullPageRaw === '1' || fullPageRaw === 'true';
817
+ const buf = await (await getPage(dlog)).screenshot({ type: 'png', fullPage });
795
818
  res.writeHead(200, {
796
819
  'Content-Type': 'image/png',
797
820
  'Content-Length': buf.length,
@@ -1077,7 +1100,7 @@ async function handleRequest(req, res, dlog) {
1077
1100
  await _context.close().catch(() => { });
1078
1101
  _context = await _browser.newContext({
1079
1102
  viewport: { width: vpWidth, height: vpHeight },
1080
- userAgent: vpUserAgent,
1103
+ userAgent: vpUserAgent ?? _defaultUserAgent,
1081
1104
  deviceScaleFactor: vpScaleFactor,
1082
1105
  isMobile: vpIsMobile,
1083
1106
  hasTouch: vpIsMobile,
@@ -142,7 +142,7 @@ class AndroidDriver {
142
142
  const resp = await this.call('viewHierarchy', {});
143
143
  return resp.hierarchy;
144
144
  }
145
- async screenshot() {
145
+ async screenshot(_opts = {}) {
146
146
  const resp = await this.call('screenshot', {});
147
147
  return Buffer.from(resp.bytes);
148
148
  }
@@ -203,7 +203,9 @@ class AndroidDriver {
203
203
  }
204
204
  async getForegroundApp() {
205
205
  const output = await this.adbOutput(['shell', 'dumpsys', 'activity', 'activities']);
206
- 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_]*)+)\//);
207
209
  if (!match)
208
210
  throw new Error('Could not determine foreground app');
209
211
  return match[1];
@@ -308,7 +308,7 @@ class IOSDriver {
308
308
  }
309
309
  return JSON.parse(data.toString('utf-8'));
310
310
  }
311
- async screenshot() {
311
+ async screenshot(_opts = {}) {
312
312
  const { status, data } = await this.request('GET', '/screenshot');
313
313
  if (status < 200 || status >= 300) {
314
314
  throw new Error(`iOS driver screenshot failed (HTTP ${status})`);
@@ -121,8 +121,9 @@ class WebDriver {
121
121
  async viewHierarchy() {
122
122
  return this.get('viewHierarchy');
123
123
  }
124
- async screenshot() {
125
- const { status, data } = await this.request('GET', '/screenshot');
124
+ async screenshot(opts = {}) {
125
+ const path = opts.fullPage ? '/screenshot?fullPage=1' : '/screenshot';
126
+ const { status, data } = await this.request('GET', path);
126
127
  if (status < 200 || status >= 300) {
127
128
  throw new Error(`Web driver screenshot failed (HTTP ${status})`);
128
129
  }
package/dist/index.js CHANGED
@@ -162,6 +162,7 @@ async function main() {
162
162
  'name',
163
163
  'device-name',
164
164
  'device-type',
165
+ 'system-image',
165
166
  'browser',
166
167
  'from',
167
168
  'to',
@@ -249,6 +250,7 @@ async function main() {
249
250
  avd: argv['avd'],
250
251
  name: argv['name'],
251
252
  deviceType: argv['device-type'],
253
+ systemImage: argv['system-image'],
252
254
  browser: argv['browser'],
253
255
  });
254
256
  break;
@@ -459,7 +461,8 @@ async function main() {
459
461
  }
460
462
  case 'take-screenshot': {
461
463
  const outPath = argv['output'];
462
- exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName);
464
+ const fullPage = Boolean(argv['full-page']);
465
+ exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage);
463
466
  break;
464
467
  }
465
468
  case 'capture-ui': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {