@houwert/conductor 0.24.0 → 0.24.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.
@@ -8,6 +8,7 @@ exports.pickAndroidArch = pickAndroidArch;
8
8
  exports.parseInstalledSystemImages = parseInstalledSystemImages;
9
9
  exports.pickSystemImage = pickSystemImage;
10
10
  exports.buildAvdmanagerCreateArgs = buildAvdmanagerCreateArgs;
11
+ exports.raiseAvdConfigRam = raiseAvdConfigRam;
11
12
  exports.startDevice = startDevice;
12
13
  exports.HELP = ` start-device
13
14
  --platform <ios|android|tvos|web|vega> Boot a simulator/emulator, start the web driver (Playwright), or boot/attach a Vega VVD
@@ -16,10 +17,14 @@ exports.HELP = ` start-device
16
17
  --name <name> Set a custom name on the device after creation (iOS/tvOS/web)
17
18
  --device-type <name> iOS/tvOS device type (e.g. "iPhone 16 Pro", "Apple TV 4K") or
18
19
  Android device profile (e.g. "pixel_7"); creates if needed
20
+ --memory <mb> Android only: RAM (MB) for a newly-created AVD (default: 4096).
21
+ Only raises; applied at creation time, never to an existing AVD
19
22
  --system-image <id> Android only: override auto-picked system image
20
23
  (e.g. "system-images;android-34;google_apis;arm64-v8a")
21
24
  --browser <chromium|firefox|webkit> Web only: which Playwright browser to launch (default: chromium)`;
22
25
  const fs_1 = __importDefault(require("fs"));
26
+ const os_1 = __importDefault(require("os"));
27
+ const path_1 = __importDefault(require("path"));
23
28
  const child_process_1 = require("child_process");
24
29
  const runner_js_1 = require("../runner.js");
25
30
  const sdk_js_1 = require("../android/sdk.js");
@@ -32,6 +37,10 @@ const cli_js_1 = require("../drivers/vega/cli.js");
32
37
  const IOS_BOOT_TIMEOUT_MS = 120000;
33
38
  const ANDROID_BOOT_TIMEOUT_MS = 120000;
34
39
  const POLL_MS = 1000;
40
+ // Stock TV device profiles default to 1024MB RAM, which OOM-kills heavy RN debug
41
+ // builds during JS bundle load. Raise freshly-created AVDs to this floor.
42
+ const ANDROID_AVD_RAM_FLOOR_MB = 4096;
43
+ const ANDROID_AVD_HEAP_MB = 512;
35
44
  async function listIOSSimulators() {
36
45
  const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', '--json']);
37
46
  if (!result.success)
@@ -496,6 +505,38 @@ function pickSystemImage(installed, apiLevel, arch) {
496
505
  function buildAvdmanagerCreateArgs(avdName, systemImage, deviceProfile) {
497
506
  return ['create', 'avd', '-n', avdName, '-k', systemImage, '-d', deviceProfile];
498
507
  }
508
+ /** Parse a config.ini RAM value; only plain integer MB counts — `M`-suffixed or bogus values are 0. */
509
+ function parsePlainMb(value) {
510
+ const m = /^(\d+)$/.exec(value.trim());
511
+ return m ? parseInt(m[1], 10) : 0;
512
+ }
513
+ /**
514
+ * Rewrite an AVD `config.ini` to raise `hw.ramSize`/`vm.heapSize` to the given floors.
515
+ * Pure and exported for tests. Values are written as plain integer MB — an `M` suffix
516
+ * makes the emulator silently fall back to 1024MB. Only ever raises; higher existing
517
+ * values are kept, and non-integer/`M`-suffixed existing values are treated as 0 so they
518
+ * get overwritten. Missing keys are appended; all other lines/ordering are preserved.
519
+ */
520
+ function raiseAvdConfigRam(configIni, targetRamMb, heapMb = ANDROID_AVD_HEAP_MB) {
521
+ const raiseKey = (contents, key, target) => {
522
+ const lines = contents.split('\n');
523
+ for (let i = 0; i < lines.length; i++) {
524
+ const m = /^(\s*)([^=\s]+)(\s*=\s*)(.*)$/.exec(lines[i]);
525
+ if (!m || m[2] !== key)
526
+ continue;
527
+ const next = Math.max(parsePlainMb(m[4]), target);
528
+ lines[i] = `${m[1]}${key}${m[3]}${next}`;
529
+ return lines.join('\n');
530
+ }
531
+ let out = contents;
532
+ if (out.length > 0 && !out.endsWith('\n'))
533
+ out += '\n';
534
+ return `${out}${key} = ${target}\n`;
535
+ };
536
+ let result = raiseKey(configIni, 'hw.ramSize', targetRamMb);
537
+ result = raiseKey(result, 'vm.heapSize', heapMb);
538
+ return result;
539
+ }
499
540
  async function listInstalledSystemImages() {
500
541
  const result = await (0, runner_js_1.spawnCommand)('sdkmanager', ['--list_installed']);
501
542
  if (!result.success) {
@@ -524,7 +565,7 @@ async function spawnAvdmanagerCreate(args) {
524
565
  proc.stdin.end('no\n');
525
566
  });
526
567
  }
527
- async function createAndroidAVD(avdName, deviceProfile, apiLevel, systemImageOverride) {
568
+ async function createAndroidAVD(avdName, deviceProfile, apiLevel, systemImageOverride, targetRamMb) {
528
569
  const arch = pickAndroidArch();
529
570
  let systemImage;
530
571
  if (systemImageOverride) {
@@ -562,6 +603,22 @@ async function createAndroidAVD(avdName, deviceProfile, apiLevel, systemImageOve
562
603
  if (!avds.includes(avdName)) {
563
604
  throw new Error(`AVD "${avdName}" was not registered after creation (not in emulator -list-avds).`);
564
605
  }
606
+ // Raise RAM above the stock profile default so heavy RN debug builds aren't OOM-killed.
607
+ const avdHome = process.env.ANDROID_AVD_HOME || path_1.default.join(os_1.default.homedir(), '.android', 'avd');
608
+ const configPath = path_1.default.join(avdHome, `${avdName}.avd`, 'config.ini');
609
+ if (fs_1.default.existsSync(configPath)) {
610
+ try {
611
+ const current = fs_1.default.readFileSync(configPath, 'utf-8');
612
+ fs_1.default.writeFileSync(configPath, raiseAvdConfigRam(current, targetRamMb), 'utf-8');
613
+ }
614
+ catch (e) {
615
+ const msg = e instanceof Error ? e.message : String(e);
616
+ console.warn(`Warning: could not raise RAM for AVD "${avdName}" (${msg}). Continuing.`);
617
+ }
618
+ }
619
+ else {
620
+ console.warn(`Warning: config.ini not found for AVD "${avdName}" at ${configPath}. Continuing.`);
621
+ }
565
622
  }
566
623
  async function waitForAndroidBoot(avdName) {
567
624
  const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS;
@@ -596,7 +653,7 @@ async function waitForAndroidBoot(avdName) {
596
653
  }
597
654
  throw new Error(`Android emulator (${avdName}) did not appear within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s`);
598
655
  }
599
- async function startAndroid(avdName, opts, deviceType, osVersion, systemImage) {
656
+ async function startAndroid(avdName, opts, deviceType, osVersion, systemImage, memory) {
600
657
  let avds;
601
658
  try {
602
659
  avds = await listAVDs();
@@ -616,8 +673,9 @@ async function startAndroid(avdName, opts, deviceType, osVersion, systemImage) {
616
673
  return 1;
617
674
  }
618
675
  console.log(`No AVD "${avdName}" found. Creating one with device profile "${deviceType}"...`);
676
+ const targetRamMb = memory && memory > 0 ? memory : ANDROID_AVD_RAM_FLOOR_MB;
619
677
  try {
620
- await createAndroidAVD(avdName, deviceType, osVersion, systemImage);
678
+ await createAndroidAVD(avdName, deviceType, osVersion, systemImage, targetRamMb);
621
679
  }
622
680
  catch (e) {
623
681
  (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
@@ -761,7 +819,7 @@ async function startDevice(platform, opts, flags) {
761
819
  case 'tvos':
762
820
  return startTvOS(flags.osVersion, opts, flags.name, flags.deviceType);
763
821
  case 'android':
764
- return startAndroid(flags.avd, opts, flags.deviceType, flags.osVersion, flags.systemImage);
822
+ return startAndroid(flags.avd, opts, flags.deviceType, flags.osVersion, flags.systemImage, flags.memory);
765
823
  case 'web':
766
824
  return startWebDriver(opts, flags.browser, flags.name);
767
825
  case 'vega':
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseAriaSnapshot = parseAriaSnapshot;
7
+ exports.mergeCanvasNodes = mergeCanvasNodes;
7
8
  exports.startWebServer = startWebServer;
8
9
  exports.stopWebServer = stopWebServer;
9
10
  exports.getCdpPort = getCdpPort;
@@ -225,11 +226,22 @@ async function resolveBoundingBoxes(page, elements) {
225
226
  const el = queue.shift();
226
227
  if (el.ref) {
227
228
  try {
228
- const locator = page.locator(`aria-ref=${el.ref}`);
229
- const box = await locator.boundingBox({ timeout: 750 });
230
- if (box && box.width > 0 && box.height > 0) {
231
- el.bounds = { x: box.x, y: box.y, width: box.width, height: box.height };
229
+ // One round-trip per ref for bounds + the element's own `data-testid`. Reading identity
230
+ // from the element itself (not by geometric overlap) is what lets canvas focus later be
231
+ // joined to the right node by identity see mergeCanvasNodes.
232
+ const info = (await page
233
+ .locator(`aria-ref=${el.ref}`)
234
+ .first()
235
+ .evaluate(`(node) => {
236
+ const r = node.getBoundingClientRect();
237
+ return { x: r.x, y: r.y, width: r.width, height: r.height,
238
+ testId: node.getAttribute('data-testid') || '' };
239
+ }`, undefined, { timeout: 750 }));
240
+ if (info.width > 0 && info.height > 0) {
241
+ el.bounds = { x: info.x, y: info.y, width: info.width, height: info.height };
232
242
  }
243
+ if (info.testId)
244
+ el.testId = info.testId;
233
245
  }
234
246
  catch {
235
247
  // Element not visible or locator failed — skip
@@ -469,44 +481,56 @@ async function stampFocusFromDocumentActiveElement(page, elements) {
469
481
  rectPick.best.focused = true;
470
482
  }
471
483
  }
472
- /** IoU + center-inside score of a candidate node's bounds against a mirror rect. */
473
- function overlapScore(bounds, m) {
474
- const x1 = Math.max(m.x, bounds.x);
475
- const y1 = Math.max(m.y, bounds.y);
476
- const x2 = Math.min(m.x + m.width, bounds.x + bounds.width);
477
- const y2 = Math.min(m.y + m.height, bounds.y + bounds.height);
478
- const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
479
- if (inter <= 0)
480
- return 0;
481
- const union = m.width * m.height + bounds.width * bounds.height - inter;
482
- const iou = union > 0 ? inter / union : 0;
483
- const mcx = m.x + m.width / 2;
484
- const mcy = m.y + m.height / 2;
485
- const centerInside = mcx >= bounds.x &&
486
- mcx <= bounds.x + bounds.width &&
487
- mcy >= bounds.y &&
488
- mcy <= bounds.y + bounds.height;
489
- return centerInside ? iou + 1 : iou;
490
- }
491
- /** Find the existing tree node whose bounds best overlap a mirror rect (>0.5 score). */
492
- function bestOverlappingNode(elements, m) {
493
- let best = null;
494
- let bestScore = 0.5;
495
- const walk = (els) => {
484
+ /**
485
+ * Merge canvas scene-graph nodes into the ARIA tree by identity (`data-testid`), never by
486
+ * geometry. A mirror node that shares a testId with an existing (natively-read) tree node
487
+ * enriches it and, authoritatively, carries its `data-focused` state onto it. Mirror nodes with
488
+ * no counterpart (canvas-only / off-screen scene nodes) are appended as their own nodes.
489
+ *
490
+ * Because focus rides identity not bounds an open drawer overlapping a focused tile can no
491
+ * longer steal the tile's focus, and a tile's identity can't smear onto a neighbour it happens
492
+ * to overlap.
493
+ */
494
+ function mergeCanvasNodes(elements, mirror) {
495
+ const byTestId = new Map();
496
+ const index = (els) => {
496
497
  for (const el of els) {
497
- if (el.bounds) {
498
- const s = overlapScore(el.bounds, m);
499
- if (s > bestScore) {
500
- bestScore = s;
501
- best = el;
502
- }
503
- }
498
+ if (el.testId)
499
+ byTestId.set(el.testId, el);
504
500
  if (el.children)
505
- walk(el.children);
501
+ index(el.children);
506
502
  }
507
503
  };
508
- walk(elements);
509
- return best;
504
+ index(elements);
505
+ for (const m of mirror) {
506
+ const bounds = m.width > 0 && m.height > 0
507
+ ? { x: m.x, y: m.y, width: m.width, height: m.height }
508
+ : undefined;
509
+ const existing = m.testId ? byTestId.get(m.testId) : undefined;
510
+ if (existing) {
511
+ if (m.focused)
512
+ existing.focused = true;
513
+ if (!existing.name && m.name)
514
+ existing.name = m.name;
515
+ if (bounds && !existing.bounds)
516
+ existing.bounds = bounds;
517
+ }
518
+ else {
519
+ const node = {
520
+ role: m.role,
521
+ name: m.name,
522
+ ref: '',
523
+ testId: m.testId || undefined,
524
+ bounds,
525
+ enabled: !m.disabled,
526
+ focused: m.focused,
527
+ children: [],
528
+ };
529
+ elements.push(node);
530
+ if (m.testId)
531
+ byTestId.set(m.testId, node);
532
+ }
533
+ }
510
534
  }
511
535
  /**
512
536
  * Harvest a canvas DOM-inspector mirror into the hierarchy. Canvas webtv frameworks
@@ -514,10 +538,11 @@ function bestOverlappingNode(elements, m) {
514
538
  * off-screen `<div>`s carrying `data-testid` (real identity) and `data-focused="true"` (focus
515
539
  * — the canvas owns `document.activeElement`, so the normal focus path can't see it).
516
540
  *
517
- * Each mirror node is matched to an existing ARIA node by bounds overlap and used to enrich it
518
- * (testId + focus); unmatched mirror nodes are appended as new nodes. Mirror rects come from
519
- * `getBoundingClientRect`, i.e. the same viewport-CSS-pixel space taps use — drive TV apps at
520
- * the app's native resolution (e.g. `set-viewport 1920 1080`) so lower nodes aren't off-screen.
541
+ * Nodes are joined to the ARIA tree by identity (see mergeCanvasNodes); canvas-only nodes are
542
+ * appended. Mirror rects come from `getBoundingClientRect`, i.e. the same viewport-CSS-pixel
543
+ * space taps use — drive TV apps at the app's native resolution (e.g. `set-viewport 1920 1080`)
544
+ * so lower scene nodes aren't off-screen. Returns true when a mirror was present (a canvas app),
545
+ * so the caller can skip the DOM `activeElement` focus fallback.
521
546
  */
522
547
  async function harvestDomMirror(page, elements) {
523
548
  let mirror = null;
@@ -541,40 +566,12 @@ async function harvestDomMirror(page, elements) {
541
566
  })()`));
542
567
  }
543
568
  catch {
544
- return;
569
+ return false;
545
570
  }
546
571
  if (!mirror || mirror.length === 0)
547
- return;
548
- // data-focused is authoritative for canvas apps (activeElement is the <canvas>, never the
549
- // focused scene node), so clear any focus already inferred from the ARIA snapshot.
550
- if (mirror.some((m) => m.focused))
551
- clearFocusedFlags(elements);
552
- for (const m of mirror) {
553
- const bounds = m.width > 0 && m.height > 0
554
- ? { x: m.x, y: m.y, width: m.width, height: m.height }
555
- : undefined;
556
- const target = bounds ? bestOverlappingNode(elements, m) : null;
557
- if (target) {
558
- if (m.testId)
559
- target.testId = m.testId;
560
- if (m.focused)
561
- target.focused = true;
562
- if (!target.name && m.name)
563
- target.name = m.name;
564
- }
565
- else {
566
- elements.push({
567
- role: m.role,
568
- name: m.name,
569
- ref: '',
570
- testId: m.testId || undefined,
571
- bounds,
572
- enabled: !m.disabled,
573
- focused: m.focused,
574
- children: [],
575
- });
576
- }
577
- }
572
+ return false;
573
+ mergeCanvasNodes(elements, mirror);
574
+ return true;
578
575
  }
579
576
  // ── Web server ───────────────────────────────────────────────────────────────
580
577
  const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
@@ -983,12 +980,16 @@ async function handleRequest(req, res, dlog) {
983
980
  await resolveBoundingBoxes(p, elements);
984
981
  await resolveBoundingBoxesBatch(p, elements);
985
982
  await resolveBoundingBoxesByRole(p, elements);
983
+ // Focus source depends on the app, and the two are mutually exclusive:
984
+ // • Canvas/inspector apps (Lightning/WPE) own document.activeElement on the <canvas>,
985
+ // so focus lives on scene nodes as `data-focused` — merged by identity.
986
+ // • Plain DOM apps expose focus via ARIA `[active]` / document.activeElement.
986
987
  clearFocusedFlags(elements);
987
- applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
988
- // Canvas webtv: merge the data-testid/data-focused mirror before falling back to
989
- // document.activeElement (which is the <canvas>, not the focused scene node).
990
- await harvestDomMirror(p, elements);
991
- await stampFocusFromDocumentActiveElement(p, elements);
988
+ const isCanvasApp = await harvestDomMirror(p, elements);
989
+ if (!isCanvasApp) {
990
+ applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
991
+ await stampFocusFromDocumentActiveElement(p, elements);
992
+ }
992
993
  jsonResponse(res, {
993
994
  url: p.url(),
994
995
  title: await p.title(),
package/dist/index.js CHANGED
@@ -370,6 +370,7 @@ async function main() {
370
370
  deviceType: argv['device-type'],
371
371
  systemImage: argv['system-image'],
372
372
  browser: argv['browser'],
373
+ memory: argv['memory'] !== undefined ? Number(argv['memory']) : undefined,
373
374
  });
374
375
  break;
375
376
  case 'list-devices':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,7 @@ conductor list-apps # installed app ids / package names
23
23
  | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
24
24
  | `conductor start-device --platform <ios\|android\|tvos\|web\|vega>` | Boot a simulator/emulator, start the web driver, or attach to a Vega VVD |
25
25
  | `conductor start-device --os-version <n> --device-type <name>` | Pick OS version + device type (creates if needed) |
26
+ | `conductor start-device --platform android --avd <name> --device-type <profile> --memory <mb>` | Create an Android AVD with a RAM floor (default 4096MB; only raises, creation-time only) |
26
27
  | `conductor stop-device [<name-or-id>] [--all]` | Shut down device(s) |
27
28
  | `conductor delete-device <name-or-id> [--all]` | Delete simulator(s)/AVD(s)/web session(s) |
28
29
  | `conductor set-location --lat <n> --lng <n>` | Set GPS coordinates |