@houwert/conductor 0.23.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.
- package/dist/commands/list-devices.js +4 -0
- package/dist/commands/start-device.js +62 -4
- package/dist/commands/web-targets.js +2 -34
- package/dist/daemon/web-server.js +80 -79
- package/dist/drivers/cdp-discovery.js +108 -0
- package/dist/index.js +12 -0
- package/package.json +1 -1
- package/skills/conductor-device-setup/SKILL.md +8 -0
|
@@ -16,6 +16,7 @@ const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
|
16
16
|
const client_js_1 = require("../daemon/client.js");
|
|
17
17
|
const protocol_js_1 = require("../daemon/protocol.js");
|
|
18
18
|
const cli_js_1 = require("../drivers/vega/cli.js");
|
|
19
|
+
const cdp_discovery_js_1 = require("../drivers/cdp-discovery.js");
|
|
19
20
|
// Captured during discoverAvailableDevices so listDevices() can report it.
|
|
20
21
|
// Module-scoped because the discover function returns Device[]; bolting an
|
|
21
22
|
// extra return field onto the public type would ripple beyond this fix.
|
|
@@ -104,6 +105,9 @@ async function discoverBootedDevices() {
|
|
|
104
105
|
catch {
|
|
105
106
|
/* vega CLI not installed */
|
|
106
107
|
}
|
|
108
|
+
// External CDP endpoints (e.g. an Electron app started with
|
|
109
|
+
// --remote-debugging-port): one device per discovered webview/tile.
|
|
110
|
+
devices.push(...(await (0, cdp_discovery_js_1.discoverCdpDevices)()));
|
|
107
111
|
return devices;
|
|
108
112
|
}
|
|
109
113
|
async function discoverAvailableDevices() {
|
|
@@ -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':
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.HELP = void 0;
|
|
7
4
|
exports.webTargets = webTargets;
|
|
@@ -14,37 +11,8 @@ exports.webTargets = webTargets;
|
|
|
14
11
|
* Playwright browser and works before any daemon session exists. Use the printed
|
|
15
12
|
* target IDs with `--cdp-url` / `--cdp-target` to bind a session to a tile.
|
|
16
13
|
*/
|
|
17
|
-
const http_1 = __importDefault(require("http"));
|
|
18
14
|
const output_js_1 = require("../output.js");
|
|
19
|
-
|
|
20
|
-
function httpBase(cdpUrl) {
|
|
21
|
-
const u = new URL(cdpUrl);
|
|
22
|
-
const proto = u.protocol === 'https:' || u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
23
|
-
return `${proto}//${u.host}`;
|
|
24
|
-
}
|
|
25
|
-
function fetchTargets(cdpUrl) {
|
|
26
|
-
const url = `${httpBase(cdpUrl)}/json/list`;
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
const req = http_1.default.get(url, (res) => {
|
|
29
|
-
const chunks = [];
|
|
30
|
-
res.on('data', (c) => chunks.push(c));
|
|
31
|
-
res.on('end', () => {
|
|
32
|
-
if ((res.statusCode ?? 0) >= 300) {
|
|
33
|
-
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
40
|
-
reject(err);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
req.setTimeout(5000, () => req.destroy(new Error(`Timed out fetching ${url}`)));
|
|
45
|
-
req.on('error', reject);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
15
|
+
const cdp_discovery_js_1 = require("../drivers/cdp-discovery.js");
|
|
48
16
|
async function webTargets(cdpUrl, opts) {
|
|
49
17
|
if (!cdpUrl) {
|
|
50
18
|
console.error('web-targets requires --cdp-url <url> (e.g. --cdp-url http://127.0.0.1:9222).\n' +
|
|
@@ -53,7 +21,7 @@ async function webTargets(cdpUrl, opts) {
|
|
|
53
21
|
}
|
|
54
22
|
let targets;
|
|
55
23
|
try {
|
|
56
|
-
targets = await
|
|
24
|
+
targets = await (0, cdp_discovery_js_1.fetchCdpTargets)(cdpUrl);
|
|
57
25
|
}
|
|
58
26
|
catch (err) {
|
|
59
27
|
console.error(`Could not reach CDP endpoint at ${cdpUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
/**
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
const
|
|
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.
|
|
498
|
-
|
|
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
|
-
|
|
501
|
+
index(el.children);
|
|
506
502
|
}
|
|
507
503
|
};
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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(),
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_CDP_PORTS = void 0;
|
|
7
|
+
exports.httpBase = httpBase;
|
|
8
|
+
exports.fetchCdpTargets = fetchCdpTargets;
|
|
9
|
+
exports.formatCdpDeviceId = formatCdpDeviceId;
|
|
10
|
+
exports.parseCdpDeviceId = parseCdpDeviceId;
|
|
11
|
+
exports.cdpTargetsToDevices = cdpTargetsToDevices;
|
|
12
|
+
exports.discoverCdpDevices = discoverCdpDevices;
|
|
13
|
+
/**
|
|
14
|
+
* Discovery of externally-launched CDP endpoints (e.g. an Electron app started
|
|
15
|
+
* with `--remote-debugging-port`, which exposes one page target per webview/tile).
|
|
16
|
+
*
|
|
17
|
+
* Unlike the Playwright web driver (which conductor launches itself) these
|
|
18
|
+
* browsers already exist — so "discovery" means finding the DevTools HTTP
|
|
19
|
+
* endpoint. CDP servers don't advertise themselves, so we probe a small range of
|
|
20
|
+
* localhost ports and enumerate each reachable endpoint's page targets via
|
|
21
|
+
* `/json/list`. The same target-fetch is reused by the `web-targets` command.
|
|
22
|
+
*/
|
|
23
|
+
const http_1 = __importDefault(require("http"));
|
|
24
|
+
/** Default localhost ports scanned for CDP endpoints. Covers Chromium/Electron's
|
|
25
|
+
* conventional `--remote-debugging-port` values without a wide scan. */
|
|
26
|
+
exports.DEFAULT_CDP_PORTS = [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229];
|
|
27
|
+
const PROBE_TIMEOUT_MS = 300;
|
|
28
|
+
/** Derive the `http://host:port` base from a CDP URL (which may be ws:// or include a path). */
|
|
29
|
+
function httpBase(cdpUrl) {
|
|
30
|
+
const u = new URL(cdpUrl);
|
|
31
|
+
const proto = u.protocol === 'https:' || u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
32
|
+
return `${proto}//${u.host}`;
|
|
33
|
+
}
|
|
34
|
+
/** GET a CDP DevTools JSON endpoint, parsing the array response. */
|
|
35
|
+
function getJson(url, timeoutMs) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const req = http_1.default.get(url, (res) => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
res.on('data', (c) => chunks.push(c));
|
|
40
|
+
res.on('end', () => {
|
|
41
|
+
if ((res.statusCode ?? 0) >= 300) {
|
|
42
|
+
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
reject(err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Timed out fetching ${url}`)));
|
|
54
|
+
req.on('error', reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Fetch the CDP page targets exposed at `cdpUrl`'s `/json/list`. */
|
|
58
|
+
function fetchCdpTargets(cdpUrl, timeoutMs = 5000) {
|
|
59
|
+
return getJson(`${httpBase(cdpUrl)}/json/list`, timeoutMs);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Device-id encoding for a discovered CDP webview: `web:cdp:<port>:<targetId>`.
|
|
63
|
+
* Self-describing so the id alone hydrates the CDP url + target at bind time —
|
|
64
|
+
* a discovered webview is drivable with no `--cdp-*` flags. Localhost is assumed
|
|
65
|
+
* (discovery only scans localhost).
|
|
66
|
+
*/
|
|
67
|
+
function formatCdpDeviceId(port, targetId) {
|
|
68
|
+
return `web:cdp:${port}:${targetId}`;
|
|
69
|
+
}
|
|
70
|
+
/** Parse a `web:cdp:<port>:<targetId>` device id, or undefined if it isn't one. */
|
|
71
|
+
function parseCdpDeviceId(deviceId) {
|
|
72
|
+
const m = /^web:cdp:(\d+):(.+)$/.exec(deviceId);
|
|
73
|
+
if (!m)
|
|
74
|
+
return undefined;
|
|
75
|
+
const port = Number(m[1]);
|
|
76
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
return { port, targetId: m[2], cdpUrl: `http://127.0.0.1:${port}` };
|
|
79
|
+
}
|
|
80
|
+
/** Map a reachable endpoint's page targets to discovered `web` devices. */
|
|
81
|
+
function cdpTargetsToDevices(port, targets) {
|
|
82
|
+
return targets
|
|
83
|
+
.filter((t) => t.type === 'page')
|
|
84
|
+
.map((t) => ({
|
|
85
|
+
id: formatCdpDeviceId(port, t.id),
|
|
86
|
+
name: t.title || t.url || t.id,
|
|
87
|
+
platform: 'web',
|
|
88
|
+
status: 'running',
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Scan localhost CDP ports and return every discovered webview as a device.
|
|
93
|
+
* Probes run in parallel with a short timeout and swallow all errors — an
|
|
94
|
+
* unreachable port simply contributes nothing. Safe to call on the hot device-
|
|
95
|
+
* resolution path.
|
|
96
|
+
*/
|
|
97
|
+
async function discoverCdpDevices(ports = exports.DEFAULT_CDP_PORTS) {
|
|
98
|
+
const results = await Promise.all(ports.map(async (port) => {
|
|
99
|
+
try {
|
|
100
|
+
const targets = await fetchCdpTargets(`http://127.0.0.1:${port}`, PROBE_TIMEOUT_MS);
|
|
101
|
+
return cdpTargetsToDevices(port, targets);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}));
|
|
107
|
+
return results.flat();
|
|
108
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -64,6 +64,7 @@ const options_js_1 = require("./commands/options.js");
|
|
|
64
64
|
const web_targets_js_1 = require("./commands/web-targets.js");
|
|
65
65
|
const session_js_2 = require("./session.js");
|
|
66
66
|
const device_picker_js_1 = require("./device-picker.js");
|
|
67
|
+
const cdp_discovery_js_1 = require("./drivers/cdp-discovery.js");
|
|
67
68
|
const update_check_js_1 = require("./update-check.js");
|
|
68
69
|
const pkg_root_js_1 = require("./pkg-root.js");
|
|
69
70
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -326,6 +327,9 @@ async function main() {
|
|
|
326
327
|
if (isWebSession && !NO_DEVICE_COMMANDS.has(command)) {
|
|
327
328
|
const cdpUrlFlag = argv['cdp-url'];
|
|
328
329
|
const cdpTargetFlag = argv['cdp-target'];
|
|
330
|
+
// A discovered `web:cdp:<port>:<target>` device id is self-describing —
|
|
331
|
+
// derive the CDP url/target from it so it's drivable with no --cdp-* flags.
|
|
332
|
+
const fromDeviceId = (0, cdp_discovery_js_1.parseCdpDeviceId)(sessionName);
|
|
329
333
|
if (cdpUrlFlag || cdpTargetFlag) {
|
|
330
334
|
if (cdpUrlFlag)
|
|
331
335
|
process.env.CONDUCTOR_CDP_URL = cdpUrlFlag;
|
|
@@ -336,6 +340,13 @@ async function main() {
|
|
|
336
340
|
cdpTargetId: process.env.CONDUCTOR_CDP_TARGET_ID,
|
|
337
341
|
}, sessionName);
|
|
338
342
|
}
|
|
343
|
+
else if (fromDeviceId) {
|
|
344
|
+
if (!process.env.CONDUCTOR_CDP_URL)
|
|
345
|
+
process.env.CONDUCTOR_CDP_URL = fromDeviceId.cdpUrl;
|
|
346
|
+
if (!process.env.CONDUCTOR_CDP_TARGET_ID) {
|
|
347
|
+
process.env.CONDUCTOR_CDP_TARGET_ID = fromDeviceId.targetId;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
339
350
|
else {
|
|
340
351
|
const saved = await (0, session_js_2.getSession)(sessionName);
|
|
341
352
|
if (saved.cdpUrl && !process.env.CONDUCTOR_CDP_URL) {
|
|
@@ -359,6 +370,7 @@ async function main() {
|
|
|
359
370
|
deviceType: argv['device-type'],
|
|
360
371
|
systemImage: argv['system-image'],
|
|
361
372
|
browser: argv['browser'],
|
|
373
|
+
memory: argv['memory'] !== undefined ? Number(argv['memory']) : undefined,
|
|
362
374
|
});
|
|
363
375
|
break;
|
|
364
376
|
case 'list-devices':
|
package/package.json
CHANGED
|
@@ -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 |
|
|
@@ -47,6 +48,13 @@ Use a distinct fully-qualified `--device web:chromium:<label>` per target (a bar
|
|
|
47
48
|
several webviews can be driven concurrently. Only `type=page` targets are
|
|
48
49
|
controllable. See [Web testing → Attaching to an existing browser](../../../docs/web.md).
|
|
49
50
|
|
|
51
|
+
**Discovery:** endpoints on the conventional debugging ports (9222–9229 on
|
|
52
|
+
localhost) are found automatically — `list-devices` shows each webview as a
|
|
53
|
+
booted `web:cdp:<port>:<targetId>` device. That id is self-describing, so you can
|
|
54
|
+
drive it directly (`conductor --device web:cdp:9222:<targetId> <cmd>`) without
|
|
55
|
+
`--cdp-url`/`--cdp-target`. Use `web-targets --cdp-url <url>` for endpoints on a
|
|
56
|
+
non-default port, or to list targets before binding.
|
|
57
|
+
|
|
50
58
|
### Vega (Amazon Fire TV)
|
|
51
59
|
|
|
52
60
|
Vega is a React Native OS driven through Amazon's own `vega`/`kepler` CLI (not
|