@houwert/conductor 0.22.0 → 0.24.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.
- package/dist/commands/assert-not-visible.js +3 -1
- package/dist/commands/assert-visible.js +3 -1
- package/dist/commands/back.js +4 -0
- package/dist/commands/capture-ui.js +4 -2
- package/dist/commands/clipboard.js +9 -0
- package/dist/commands/crashes.js +5 -0
- package/dist/commands/delete-device.js +6 -1
- package/dist/commands/download-app.js +4 -0
- package/dist/commands/erase-text.js +2 -1
- package/dist/commands/focused.js +3 -1
- package/dist/commands/foreground-app.js +2 -1
- package/dist/commands/gestures.js +7 -0
- package/dist/commands/hide-keyboard.js +4 -0
- package/dist/commands/inspect.js +4 -3
- package/dist/commands/install-app.js +11 -0
- package/dist/commands/launch-app.js +6 -0
- package/dist/commands/list-apps.js +5 -0
- package/dist/commands/list-devices.js +20 -0
- package/dist/commands/memory.js +5 -0
- package/dist/commands/press-key.js +32 -4
- package/dist/commands/profile.js +5 -0
- package/dist/commands/screenshot.js +3 -1
- package/dist/commands/scroll-until-visible.js +3 -1
- package/dist/commands/scroll.js +2 -1
- package/dist/commands/start-device.js +60 -3
- package/dist/commands/stop-app.js +4 -0
- package/dist/commands/stop-device.js +8 -3
- package/dist/commands/swipe.js +2 -1
- package/dist/commands/tap.js +9 -6
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/commands/web-targets.js +2 -34
- package/dist/commands/workspace.js +4 -1
- package/dist/daemon/log-collector.js +9 -1
- package/dist/daemon/server.js +70 -50
- package/dist/drivers/bootstrap.js +12 -0
- package/dist/drivers/cdp-discovery.js +108 -0
- package/dist/drivers/flow-runner.js +38 -8
- package/dist/drivers/ios.js +5 -2
- package/dist/drivers/log-sources/metro-discovery.js +43 -0
- package/dist/drivers/log-sources/vega.js +77 -0
- package/dist/drivers/vega/automation-client.js +79 -0
- package/dist/drivers/vega/cli.js +199 -0
- package/dist/drivers/vega/connection.js +48 -0
- package/dist/drivers/vega/input.js +100 -0
- package/dist/drivers/vega/page-source-parser.js +229 -0
- package/dist/drivers/vega.js +165 -0
- package/dist/enum-options.js +15 -3
- package/dist/index.js +18 -2
- package/dist/runner.js +31 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +4 -3
- package/skills/conductor-device-setup/SKILL.md +23 -2
|
@@ -17,6 +17,7 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
|
17
17
|
const ios_js_1 = require("./ios.js");
|
|
18
18
|
const android_js_1 = require("./android.js");
|
|
19
19
|
const web_js_1 = require("./web.js");
|
|
20
|
+
const vega_js_1 = require("./vega.js");
|
|
20
21
|
const wait_js_1 = require("./wait.js");
|
|
21
22
|
const direct_ios_selector_js_1 = require("./direct-ios-selector.js");
|
|
22
23
|
const perf_hooks_1 = require("perf_hooks");
|
|
@@ -25,6 +26,20 @@ const utils_js_1 = require("../utils.js");
|
|
|
25
26
|
function fmtMs(ms) {
|
|
26
27
|
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
27
28
|
}
|
|
29
|
+
// vega (Amazon Fire TV) remote key names → VegaDriver button values, for flow pressKey.
|
|
30
|
+
const VEGA_FLOW_BUTTONS = {
|
|
31
|
+
BACK: 'back',
|
|
32
|
+
HOME: 'home',
|
|
33
|
+
'REMOTE DPAD UP': 'up',
|
|
34
|
+
'REMOTE DPAD DOWN': 'down',
|
|
35
|
+
'REMOTE DPAD LEFT': 'left',
|
|
36
|
+
'REMOTE DPAD RIGHT': 'right',
|
|
37
|
+
'REMOTE DPAD CENTER': 'select',
|
|
38
|
+
ENTER: 'select',
|
|
39
|
+
RETURN: 'select',
|
|
40
|
+
VOLUME_UP: 'volumeUp',
|
|
41
|
+
VOLUME_DOWN: 'volumeDown',
|
|
42
|
+
};
|
|
28
43
|
function toElementSelector(sel) {
|
|
29
44
|
// A bare string matches text OR id (Maestro's query semantics).
|
|
30
45
|
// Only object form with explicit `text:` or `id:` keys forces one field.
|
|
@@ -507,7 +522,13 @@ async function executeCommand(cmd, driver, opts) {
|
|
|
507
522
|
}
|
|
508
523
|
}
|
|
509
524
|
function getConductorObj(driver, output) {
|
|
510
|
-
const platform = driver instanceof ios_js_1.IOSDriver
|
|
525
|
+
const platform = driver instanceof ios_js_1.IOSDriver
|
|
526
|
+
? 'ios'
|
|
527
|
+
: driver instanceof web_js_1.WebDriver
|
|
528
|
+
? 'web'
|
|
529
|
+
: driver instanceof vega_js_1.VegaDriver
|
|
530
|
+
? 'vega'
|
|
531
|
+
: 'android';
|
|
511
532
|
return {
|
|
512
533
|
platform,
|
|
513
534
|
copiedText: output['__copiedText'] ?? '',
|
|
@@ -634,16 +655,14 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
634
655
|
const n = typeof val === 'number'
|
|
635
656
|
? val
|
|
636
657
|
: (val?.charactersToErase ?? 50);
|
|
637
|
-
if (driver instanceof
|
|
638
|
-
await driver.eraseAllText(n);
|
|
639
|
-
}
|
|
640
|
-
else if (driver instanceof web_js_1.WebDriver) {
|
|
641
|
-
await driver.eraseAllText(n);
|
|
642
|
-
}
|
|
643
|
-
else {
|
|
658
|
+
if (driver instanceof ios_js_1.IOSDriver) {
|
|
644
659
|
for (let i = 0; i < n; i++)
|
|
645
660
|
await driver.pressKey('delete');
|
|
646
661
|
}
|
|
662
|
+
else {
|
|
663
|
+
// Android, web, and vega all expose eraseAllText.
|
|
664
|
+
await driver.eraseAllText(n);
|
|
665
|
+
}
|
|
647
666
|
break;
|
|
648
667
|
}
|
|
649
668
|
case 'inputRandomText': {
|
|
@@ -782,6 +801,8 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
782
801
|
await driver.back();
|
|
783
802
|
else if (driver instanceof web_js_1.WebDriver)
|
|
784
803
|
await driver.goBack();
|
|
804
|
+
else if (driver instanceof vega_js_1.VegaDriver)
|
|
805
|
+
await driver.back();
|
|
785
806
|
// iOS has no hardware back button — noop
|
|
786
807
|
break;
|
|
787
808
|
}
|
|
@@ -1055,6 +1076,12 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
1055
1076
|
};
|
|
1056
1077
|
await driver.pressKey(WEB_KEY_MAP[keyName] ?? keyName);
|
|
1057
1078
|
}
|
|
1079
|
+
else if (driver instanceof vega_js_1.VegaDriver) {
|
|
1080
|
+
const button = VEGA_FLOW_BUTTONS[keyName];
|
|
1081
|
+
if (!button)
|
|
1082
|
+
throw new Error(`pressKey: key "${val}" is not supported on vega`);
|
|
1083
|
+
await driver.pressButton(button);
|
|
1084
|
+
}
|
|
1058
1085
|
else {
|
|
1059
1086
|
const keycode = ANDROID_KEYCODES[keyName];
|
|
1060
1087
|
if (keycode === undefined)
|
|
@@ -1072,6 +1099,9 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
1072
1099
|
else if (driver instanceof web_js_1.WebDriver) {
|
|
1073
1100
|
// No virtual keyboard on web — noop
|
|
1074
1101
|
}
|
|
1102
|
+
else if (driver instanceof vega_js_1.VegaDriver) {
|
|
1103
|
+
// No reliable keyboard-hide primitive on vega — noop
|
|
1104
|
+
}
|
|
1075
1105
|
else {
|
|
1076
1106
|
await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
|
|
1077
1107
|
}
|
package/dist/drivers/ios.js
CHANGED
|
@@ -160,8 +160,11 @@ class IOSDriver {
|
|
|
160
160
|
await this.post('pressKey', { key });
|
|
161
161
|
this.invalidateHierarchyCache();
|
|
162
162
|
}
|
|
163
|
-
async pressButton(button) {
|
|
164
|
-
await this.post('pressButton', {
|
|
163
|
+
async pressButton(button, duration) {
|
|
164
|
+
await this.post('pressButton', {
|
|
165
|
+
button,
|
|
166
|
+
...(duration !== undefined ? { duration } : {}),
|
|
167
|
+
});
|
|
165
168
|
this.invalidateHierarchyCache();
|
|
166
169
|
}
|
|
167
170
|
async launchApp(bundleId, args) {
|
|
@@ -26,6 +26,10 @@ exports.targetsForDevice = targetsForDevice;
|
|
|
26
26
|
const child_process_1 = require("child_process");
|
|
27
27
|
const metro_js_1 = require("./metro.js");
|
|
28
28
|
const sdk_js_1 = require("../../android/sdk.js");
|
|
29
|
+
const cli_js_1 = require("../vega/cli.js");
|
|
30
|
+
// Standard Metro dev-server ports to probe for the Vega VVD, which — unlike
|
|
31
|
+
// iOS/Android — exposes no host-visible socket mapping we can read.
|
|
32
|
+
const VEGA_METRO_CANDIDATE_PORTS = [8081, 8082];
|
|
29
33
|
/**
|
|
30
34
|
* Heuristic to reject ports that obviously aren't a Metro dev server (system
|
|
31
35
|
* ports, well-known services). Candidates that pass are always verified via
|
|
@@ -62,6 +66,33 @@ async function discoverMetroPortForDevice(platform, deviceId) {
|
|
|
62
66
|
if (platform === 'ios' || platform === 'tvos') {
|
|
63
67
|
return discoverMetroPortIOS(deviceId);
|
|
64
68
|
}
|
|
69
|
+
if (platform === 'vega') {
|
|
70
|
+
return discoverMetroPortVega(deviceId);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Locate the Metro port for a Vega VVD. There is no host-visible socket mapping,
|
|
76
|
+
* so probe the standard Metro ports and accept the first that is a real Metro
|
|
77
|
+
* server hosting a target for this device (matched by display name when known).
|
|
78
|
+
*/
|
|
79
|
+
async function discoverMetroPortVega(deviceId) {
|
|
80
|
+
const displayName = await getDeviceDisplayName('vega', deviceId);
|
|
81
|
+
for (const port of VEGA_METRO_CANDIDATE_PORTS) {
|
|
82
|
+
try {
|
|
83
|
+
const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
|
|
84
|
+
if (targets.length === 0)
|
|
85
|
+
continue;
|
|
86
|
+
// Prefer a name match; fall back to any target (single-device v1).
|
|
87
|
+
if (!displayName || targets.some((t) => deviceNameMatches(t.deviceName, displayName))) {
|
|
88
|
+
return port;
|
|
89
|
+
}
|
|
90
|
+
return port;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// not Metro on this port — try the next
|
|
94
|
+
}
|
|
95
|
+
}
|
|
65
96
|
return null;
|
|
66
97
|
}
|
|
67
98
|
async function discoverMetroPortAndroid(deviceId) {
|
|
@@ -199,6 +230,18 @@ async function getDeviceDisplayName(platform, deviceId) {
|
|
|
199
230
|
return null;
|
|
200
231
|
}
|
|
201
232
|
}
|
|
233
|
+
if (platform === 'vega') {
|
|
234
|
+
// deviceId is `vega:<serial>`; resolve the VVD's reported description.
|
|
235
|
+
const serial = deviceId.replace(/^vega:/, '');
|
|
236
|
+
try {
|
|
237
|
+
const devices = await new cli_js_1.VegaCli().listDevices();
|
|
238
|
+
const match = devices.find((d) => d.serial === serial);
|
|
239
|
+
return match?.description ?? serial;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return serial;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
202
245
|
return null;
|
|
203
246
|
}
|
|
204
247
|
/**
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VegaLogSource = void 0;
|
|
4
|
+
const cli_js_1 = require("../vega/cli.js");
|
|
5
|
+
/** Map a Vega/Android-style level token to a LogEntry level. */
|
|
6
|
+
function mapLevel(token) {
|
|
7
|
+
switch (token.toUpperCase()) {
|
|
8
|
+
case 'V':
|
|
9
|
+
case 'VERBOSE':
|
|
10
|
+
return 'verbose';
|
|
11
|
+
case 'D':
|
|
12
|
+
case 'DEBUG':
|
|
13
|
+
return 'debug';
|
|
14
|
+
case 'I':
|
|
15
|
+
case 'INFO':
|
|
16
|
+
return 'info';
|
|
17
|
+
case 'W':
|
|
18
|
+
case 'WARN':
|
|
19
|
+
case 'WARNING':
|
|
20
|
+
return 'warning';
|
|
21
|
+
case 'E':
|
|
22
|
+
case 'ERROR':
|
|
23
|
+
case 'F':
|
|
24
|
+
case 'FATAL':
|
|
25
|
+
return 'error';
|
|
26
|
+
default:
|
|
27
|
+
return 'log';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
class VegaLogSource {
|
|
31
|
+
constructor(serial) {
|
|
32
|
+
this.serial = serial;
|
|
33
|
+
this.proc = null;
|
|
34
|
+
this.callback = null;
|
|
35
|
+
this.buffer = '';
|
|
36
|
+
}
|
|
37
|
+
async connect() {
|
|
38
|
+
this.proc = new cli_js_1.VegaCli(this.serial).startLogStream();
|
|
39
|
+
const onData = (chunk) => {
|
|
40
|
+
this.buffer += chunk.toString('utf-8');
|
|
41
|
+
const lines = this.buffer.split('\n');
|
|
42
|
+
this.buffer = lines.pop() ?? '';
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (line.trim())
|
|
45
|
+
this.emit(line);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
this.proc.stdout?.on('data', onData);
|
|
49
|
+
this.proc.stderr?.on('data', onData);
|
|
50
|
+
this.proc.on('error', () => {
|
|
51
|
+
/* vega CLI not available — no logs */
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
onEntry(callback) {
|
|
55
|
+
this.callback = callback;
|
|
56
|
+
}
|
|
57
|
+
disconnect() {
|
|
58
|
+
if (this.proc) {
|
|
59
|
+
this.proc.kill('SIGTERM');
|
|
60
|
+
this.proc = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
emit(line) {
|
|
64
|
+
// Best-effort level extraction from a leading/embedded level token; the raw
|
|
65
|
+
// line is always preserved as the message.
|
|
66
|
+
const levelMatch = /\b([VDIWEF]|VERBOSE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\b/.exec(line);
|
|
67
|
+
const entry = {
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
level: levelMatch ? mapLevel(levelMatch[1]) : 'log',
|
|
70
|
+
message: line,
|
|
71
|
+
stackTrace: null,
|
|
72
|
+
source: 'device',
|
|
73
|
+
};
|
|
74
|
+
this.callback?.(entry);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.VegaLogSource = VegaLogSource;
|
|
@@ -0,0 +1,79 @@
|
|
|
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.VegaAutomationClient = exports.VegaToolkitUnavailableError = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Talks to the on-device automation toolkit (the accessibility server Amazon's
|
|
9
|
+
* Appium Vega driver uses), which serves JSON-RPC on device TCP port 8383.
|
|
10
|
+
*
|
|
11
|
+
* Vega is not adb, so there is no host-side `adb forward`. Instead we run `curl`
|
|
12
|
+
* on the device (`vega … run-cmd`), writing the JSON-RPC response to a device file
|
|
13
|
+
* with `curl -o` and pulling it to the host with `copy-from`. Routing through a
|
|
14
|
+
* file (rather than the command's stdout) is required: `run-cmd` truncates large
|
|
15
|
+
* stdout — a full-screen screenshot PNG and a deep page-source tree both exceed it.
|
|
16
|
+
*/
|
|
17
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const TOOLKIT_PORT = 8383;
|
|
21
|
+
class VegaToolkitUnavailableError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'VegaToolkitUnavailableError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.VegaToolkitUnavailableError = VegaToolkitUnavailableError;
|
|
28
|
+
class VegaAutomationClient {
|
|
29
|
+
constructor(connection) {
|
|
30
|
+
this.connection = connection;
|
|
31
|
+
}
|
|
32
|
+
/** Current screen's page-source XML. */
|
|
33
|
+
async getPageSource() {
|
|
34
|
+
const result = await this.call('getPageSource');
|
|
35
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
36
|
+
}
|
|
37
|
+
/** Current screen as PNG bytes (base64 in the RPC result). */
|
|
38
|
+
async getScreenshot() {
|
|
39
|
+
const result = await this.call('takeScreenshot');
|
|
40
|
+
if (typeof result !== 'string') {
|
|
41
|
+
throw new VegaToolkitUnavailableError('takeScreenshot returned no image data');
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from(result, 'base64');
|
|
44
|
+
}
|
|
45
|
+
/** POST a parameterless JSON-RPC [method] to the toolkit and return its `result`. */
|
|
46
|
+
async call(method) {
|
|
47
|
+
const devicePath = `/tmp/conductor-vega-${method}.json`;
|
|
48
|
+
const payload = `{"jsonrpc":"2.0","id":1,"method":"${method}","params":{}}`;
|
|
49
|
+
await this.connection.shell(`curl -s -X POST -H 'Content-Type: application/json' -d '${payload}' ` +
|
|
50
|
+
`-o ${devicePath} http://127.0.0.1:${TOOLKIT_PORT}/jsonrpc`);
|
|
51
|
+
const hostFile = path_1.default.join(os_1.default.tmpdir(), `conductor-vega-${method}-${process.pid}-${TOOLKIT_PORT}.json`);
|
|
52
|
+
try {
|
|
53
|
+
await this.connection.copyFrom(devicePath, hostFile);
|
|
54
|
+
const raw = await promises_1.default.readFile(hostFile, 'utf-8').catch(() => '');
|
|
55
|
+
if (!raw) {
|
|
56
|
+
throw new VegaToolkitUnavailableError(`Empty response from the Vega automation toolkit (method=${method}). The toolkit ` +
|
|
57
|
+
`attaches at app launch after the enable flag is set — relaunch the app under test.`);
|
|
58
|
+
}
|
|
59
|
+
let node;
|
|
60
|
+
try {
|
|
61
|
+
node = JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new VegaToolkitUnavailableError(`Malformed toolkit response for ${method}`);
|
|
65
|
+
}
|
|
66
|
+
if (node.error !== undefined && node.error !== null) {
|
|
67
|
+
throw new VegaToolkitUnavailableError(`Toolkit error for ${method}: ${JSON.stringify(node.error)}`);
|
|
68
|
+
}
|
|
69
|
+
if (node.result === undefined) {
|
|
70
|
+
throw new VegaToolkitUnavailableError(`Toolkit response for ${method} had no result`);
|
|
71
|
+
}
|
|
72
|
+
return node.result;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await promises_1.default.rm(hostFile, { force: true }).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
exports.VegaAutomationClient = VegaAutomationClient;
|
|
@@ -0,0 +1,199 @@
|
|
|
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.VegaCli = void 0;
|
|
7
|
+
exports.commandSucceeded = commandSucceeded;
|
|
8
|
+
exports.commandOutput = commandOutput;
|
|
9
|
+
/**
|
|
10
|
+
* Thin wrapper over Amazon's Vega developer CLI. Vega is not Android, so we drive
|
|
11
|
+
* it through the SDK's own binary (`vega`/`kepler`/`vda`) rather than adb — this is
|
|
12
|
+
* the only supported channel and keeps the same path working for physical Fire TVs.
|
|
13
|
+
*
|
|
14
|
+
* Command surface (verified against the Vega SDK via Maestro's reference driver):
|
|
15
|
+
* - `vega device list` prints one `<selector> : <profile> - <arch> - <os> - <host>`
|
|
16
|
+
* line per device; the `-d` selector is the first field (e.g. `VirtualDevice`).
|
|
17
|
+
* - on-device commands run via `vega device run-cmd -d <sel> -c '<cmd>'` (there is
|
|
18
|
+
* no `shell -c`; plain `shell` is interactive).
|
|
19
|
+
* - `-d` is a per-subcommand option, so it follows the subcommand name.
|
|
20
|
+
*/
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const verbose_js_1 = require("../../verbose.js");
|
|
26
|
+
function commandSucceeded(r) {
|
|
27
|
+
return r.exitCode === 0;
|
|
28
|
+
}
|
|
29
|
+
function commandOutput(r) {
|
|
30
|
+
return `${r.stdout}\n${r.stderr}`.trim();
|
|
31
|
+
}
|
|
32
|
+
let _resolvedBinary = null;
|
|
33
|
+
class VegaCli {
|
|
34
|
+
constructor(serial, binary) {
|
|
35
|
+
this.serial = serial;
|
|
36
|
+
this.binary = binary ?? VegaCli.resolveBinary();
|
|
37
|
+
}
|
|
38
|
+
/** Run a raw CLI invocation and capture output. */
|
|
39
|
+
exec(args, timeoutSeconds = 120) {
|
|
40
|
+
(0, verbose_js_1.log)(`vega cli: ${this.binary} ${args.join(' ')}`);
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const proc = (0, child_process_1.spawn)(this.binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
43
|
+
let stdout = '';
|
|
44
|
+
let stderr = '';
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
proc.kill('SIGKILL');
|
|
47
|
+
}, timeoutSeconds * 1000);
|
|
48
|
+
proc.stdout.on('data', (c) => {
|
|
49
|
+
stdout += c.toString();
|
|
50
|
+
});
|
|
51
|
+
proc.stderr.on('data', (c) => {
|
|
52
|
+
stderr += c.toString();
|
|
53
|
+
});
|
|
54
|
+
proc.on('close', (code) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve({ exitCode: code ?? 1, stdout, stderr });
|
|
57
|
+
});
|
|
58
|
+
proc.on('error', (err) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve({ exitCode: 127, stdout: '', stderr: err.message });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Build `device <subcommand> [-d serial] <rest…>` — `-d` follows the subcommand. */
|
|
65
|
+
deviceArgs(subcommand, ...rest) {
|
|
66
|
+
const selector = this.serial ? ['-d', this.serial] : [];
|
|
67
|
+
return ['device', subcommand, ...selector, ...rest];
|
|
68
|
+
}
|
|
69
|
+
deviceExec(subcommand, rest = [], timeoutSeconds = 120) {
|
|
70
|
+
return this.exec(this.deviceArgs(subcommand, ...rest), timeoutSeconds);
|
|
71
|
+
}
|
|
72
|
+
async listDevices() {
|
|
73
|
+
const result = await this.exec(['device', 'list']);
|
|
74
|
+
if (!commandSucceeded(result)) {
|
|
75
|
+
(0, verbose_js_1.log)(`\`vega device list\` failed: ${commandOutput(result)}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
return VegaCli.parseDeviceList(result.stdout);
|
|
79
|
+
}
|
|
80
|
+
/** Run a shell command on the device and return its stdout (via `run-cmd -c`). */
|
|
81
|
+
async shell(command) {
|
|
82
|
+
const result = await this.deviceExec('run-cmd', ['-c', command]);
|
|
83
|
+
if (!commandSucceeded(result)) {
|
|
84
|
+
throw new Error(`Vega run-cmd failed: ${commandOutput(result)}`);
|
|
85
|
+
}
|
|
86
|
+
return result.stdout;
|
|
87
|
+
}
|
|
88
|
+
/** Copy a device file to the host via `copy-from` (a file transfer, no stdout limit). */
|
|
89
|
+
async copyFrom(remotePath, localPath) {
|
|
90
|
+
const result = await this.deviceExec('copy-from', ['-s', remotePath, '-o', localPath]);
|
|
91
|
+
if (!commandSucceeded(result)) {
|
|
92
|
+
throw new Error(`Failed to copy ${remotePath} from device: ${commandOutput(result)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async launchApp(appId) {
|
|
96
|
+
const result = await this.deviceExec('launch-app', ['-a', appId]);
|
|
97
|
+
if (!commandSucceeded(result)) {
|
|
98
|
+
throw new Error(`Failed to launch ${appId}: ${commandOutput(result)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async terminateApp(appId) {
|
|
102
|
+
const result = await this.deviceExec('terminate-app', ['-a', appId]);
|
|
103
|
+
if (!commandSucceeded(result)) {
|
|
104
|
+
(0, verbose_js_1.log)(`Failed to terminate ${appId}: ${commandOutput(result)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async installApp(vpkgPath) {
|
|
108
|
+
const result = await this.deviceExec('install-app', ['-p', vpkgPath], 300);
|
|
109
|
+
if (!commandSucceeded(result)) {
|
|
110
|
+
throw new Error(`Failed to install ${vpkgPath}: ${commandOutput(result)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async listInstalledApps() {
|
|
114
|
+
const result = await this.deviceExec('installed-apps');
|
|
115
|
+
if (!commandSucceeded(result))
|
|
116
|
+
return [];
|
|
117
|
+
return result.stdout
|
|
118
|
+
.split('\n')
|
|
119
|
+
.map((l) => l.trim())
|
|
120
|
+
.filter((l) => l.length > 0 && l.includes('.') && !l.includes(' '));
|
|
121
|
+
}
|
|
122
|
+
/** Start streaming device logs with stdout/stderr piped; caller owns the process. */
|
|
123
|
+
startLogStream() {
|
|
124
|
+
return (0, child_process_1.spawn)(this.binary, this.deviceArgs('start-log-stream'), {
|
|
125
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Boot a Vega Virtual Device via `vega virtual-device start [<name>]`, detached
|
|
130
|
+
* like the Android emulator — the process runs the VVD in the background and the
|
|
131
|
+
* caller polls {@link listDevices} for readiness. Returns the spawned process.
|
|
132
|
+
*/
|
|
133
|
+
spawnVirtualDeviceStart(name) {
|
|
134
|
+
const args = ['virtual-device', 'start', ...(name ? [name] : [])];
|
|
135
|
+
const proc = (0, child_process_1.spawn)(this.binary, args, { detached: true, stdio: 'ignore' });
|
|
136
|
+
proc.unref();
|
|
137
|
+
return proc;
|
|
138
|
+
}
|
|
139
|
+
// Device lines look like: `VirtualDevice : tv - aarch64 - OS - amazon-<hostname>`.
|
|
140
|
+
// The `-d` selector is the first field (before " : "), not the trailing hostname.
|
|
141
|
+
static parseDeviceList(output) {
|
|
142
|
+
return output
|
|
143
|
+
.split('\n')
|
|
144
|
+
.map((l) => l.trim())
|
|
145
|
+
.filter((l) => l.includes(' : '))
|
|
146
|
+
.map((line) => {
|
|
147
|
+
const serial = line.slice(0, line.indexOf(' : ')).trim();
|
|
148
|
+
if (!serial)
|
|
149
|
+
return null;
|
|
150
|
+
const isVirtual = /^virtualdevice$/i.test(serial) || /^simulator$/i.test(serial) || /virtual/i.test(line);
|
|
151
|
+
return { serial, description: line, isVirtual };
|
|
152
|
+
})
|
|
153
|
+
.filter((d) => d !== null);
|
|
154
|
+
}
|
|
155
|
+
/** Resolve the Vega CLI binary: env override → PATH probe → ~/vega/bin → `vega`. */
|
|
156
|
+
static resolveBinary() {
|
|
157
|
+
if (_resolvedBinary)
|
|
158
|
+
return _resolvedBinary;
|
|
159
|
+
const envOverride = process.env.CONDUCTOR_VEGA_CLI;
|
|
160
|
+
if (envOverride && envOverride.trim()) {
|
|
161
|
+
_resolvedBinary = envOverride.trim();
|
|
162
|
+
return _resolvedBinary;
|
|
163
|
+
}
|
|
164
|
+
const candidates = ['vega', 'kepler', 'vda'];
|
|
165
|
+
for (const candidate of candidates) {
|
|
166
|
+
if (isOnPath(candidate)) {
|
|
167
|
+
_resolvedBinary = candidate;
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const binDir = path_1.default.join(os_1.default.homedir(), 'vega', 'bin');
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
const full = path_1.default.join(binDir, candidate);
|
|
174
|
+
if (fs_1.default.existsSync(full)) {
|
|
175
|
+
_resolvedBinary = full;
|
|
176
|
+
return full;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Fall back to `vega`; the first invocation surfaces a clear "command not found".
|
|
180
|
+
_resolvedBinary = 'vega';
|
|
181
|
+
return _resolvedBinary;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.VegaCli = VegaCli;
|
|
185
|
+
function isOnPath(command) {
|
|
186
|
+
const dirs = (process.env.PATH ?? '').split(path_1.default.delimiter);
|
|
187
|
+
for (const dir of dirs) {
|
|
188
|
+
if (!dir)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
fs_1.default.accessSync(path_1.default.join(dir, command), fs_1.default.constants.X_OK);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* not here */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VegaDeviceConnection = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* On-device operations for a single Vega target, all routed through {@link VegaCli}.
|
|
6
|
+
*
|
|
7
|
+
* The automation toolkit that serves the view hierarchy (device port 8383) only
|
|
8
|
+
* attaches when the flag file exists *at app launch*, so {@link ensureToolkitEnabled}
|
|
9
|
+
* must be called before launching the app under test.
|
|
10
|
+
*/
|
|
11
|
+
const cli_js_1 = require("./cli.js");
|
|
12
|
+
const verbose_js_1 = require("../../verbose.js");
|
|
13
|
+
const TOOLKIT_ENABLE_FLAG = '/tmp/automation-toolkit.enable';
|
|
14
|
+
const SCREEN_SIZE_RE = /(\d+)\s*x\s*(\d+)/;
|
|
15
|
+
class VegaDeviceConnection {
|
|
16
|
+
constructor(serial, cli = new cli_js_1.VegaCli(serial)) {
|
|
17
|
+
this.serial = serial;
|
|
18
|
+
this.cli = cli;
|
|
19
|
+
}
|
|
20
|
+
shell(command) {
|
|
21
|
+
return this.cli.shell(command);
|
|
22
|
+
}
|
|
23
|
+
copyFrom(remotePath, localPath) {
|
|
24
|
+
return this.cli.copyFrom(remotePath, localPath);
|
|
25
|
+
}
|
|
26
|
+
/** Idempotently create the toolkit enable flag (read at app launch). */
|
|
27
|
+
async ensureToolkitEnabled() {
|
|
28
|
+
try {
|
|
29
|
+
await this.shell(`touch ${TOOLKIT_ENABLE_FLAG}`);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
(0, verbose_js_1.log)(`Failed to enable Vega automation toolkit flag: ${String(err)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Device screen size via `inputd-cli get_screen_size`. This is also the
|
|
37
|
+
* developer-mode gate: with dev mode off the on-device shell service is down
|
|
38
|
+
* and no "<W> x <H>" prints.
|
|
39
|
+
*/
|
|
40
|
+
async screenSize() {
|
|
41
|
+
const out = (await this.shell('inputd-cli get_screen_size')).trim();
|
|
42
|
+
const match = SCREEN_SIZE_RE.exec(out);
|
|
43
|
+
if (!match)
|
|
44
|
+
return null;
|
|
45
|
+
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.VegaDeviceConnection = VegaDeviceConnection;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VegaInput = exports.VegaInputUnavailableError = exports.VEGA_BUTTON_KEYS = void 0;
|
|
4
|
+
exports.shellQuote = shellQuote;
|
|
5
|
+
const SETTLE_MS = 300;
|
|
6
|
+
const LONG_PRESS_MS = 1000;
|
|
7
|
+
// Verified key names (via Maestro): select is KEY_ENTER (KEY_SELECT is a no-op),
|
|
8
|
+
// home is KEY_HOMEPAGE (KEY_HOME is inert), back is KEY_BACK.
|
|
9
|
+
exports.VEGA_BUTTON_KEYS = {
|
|
10
|
+
up: 'KEY_UP',
|
|
11
|
+
down: 'KEY_DOWN',
|
|
12
|
+
left: 'KEY_LEFT',
|
|
13
|
+
right: 'KEY_RIGHT',
|
|
14
|
+
select: 'KEY_ENTER',
|
|
15
|
+
menu: 'KEY_MENU',
|
|
16
|
+
home: 'KEY_HOMEPAGE',
|
|
17
|
+
back: 'KEY_BACK',
|
|
18
|
+
playPause: 'KEY_PLAYPAUSE',
|
|
19
|
+
rewind: 'KEY_REWIND',
|
|
20
|
+
fastForward: 'KEY_FASTFORWARD',
|
|
21
|
+
volumeUp: 'KEY_VOLUMEUP',
|
|
22
|
+
volumeDown: 'KEY_VOLUMEDOWN',
|
|
23
|
+
};
|
|
24
|
+
class VegaInputUnavailableError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'VegaInputUnavailableError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.VegaInputUnavailableError = VegaInputUnavailableError;
|
|
31
|
+
class VegaInput {
|
|
32
|
+
constructor(connection) {
|
|
33
|
+
this.connection = connection;
|
|
34
|
+
this.inputChecked = false;
|
|
35
|
+
}
|
|
36
|
+
/** Probe the input channel once; raises an actionable error if dev mode is off. */
|
|
37
|
+
async ensureInputAvailable() {
|
|
38
|
+
if (this.inputChecked)
|
|
39
|
+
return;
|
|
40
|
+
const size = await this.connection.screenSize().catch(() => null);
|
|
41
|
+
if (!size) {
|
|
42
|
+
throw new VegaInputUnavailableError('Vega input is unavailable: `inputd-cli` could not be reached over the device shell, ' +
|
|
43
|
+
"which means the VVD's developer mode is off. Enable it (`vsm developer-mode enable`, " +
|
|
44
|
+
'e.g. via `vega device shell`) and retry.');
|
|
45
|
+
}
|
|
46
|
+
this.inputChecked = true;
|
|
47
|
+
}
|
|
48
|
+
async pressButton(button) {
|
|
49
|
+
const keyName = exports.VEGA_BUTTON_KEYS[button];
|
|
50
|
+
if (!keyName)
|
|
51
|
+
throw new Error(`Button "${button}" is not supported on Vega`);
|
|
52
|
+
await this.buttonPress(keyName);
|
|
53
|
+
}
|
|
54
|
+
async buttonPress(keyName) {
|
|
55
|
+
await this.ensureInputAvailable();
|
|
56
|
+
await this.connection.shell(`inputd-cli button_press ${keyName}`);
|
|
57
|
+
await settle();
|
|
58
|
+
}
|
|
59
|
+
async tap(x, y) {
|
|
60
|
+
await this.ensureInputAvailable();
|
|
61
|
+
await this.connection.shell(`inputd-cli touch ${Math.round(x)} ${Math.round(y)}`);
|
|
62
|
+
await settle();
|
|
63
|
+
}
|
|
64
|
+
async longPress(x, y) {
|
|
65
|
+
await this.ensureInputAvailable();
|
|
66
|
+
// Vega expresses a long press via the hold duration on a touch.
|
|
67
|
+
await this.connection.shell(`inputd-cli touch ${Math.round(x)} ${Math.round(y)} --holdDuration ${LONG_PRESS_MS}`);
|
|
68
|
+
await settle();
|
|
69
|
+
}
|
|
70
|
+
async swipe(startX, startY, endX, endY, durationMs) {
|
|
71
|
+
await this.ensureInputAvailable();
|
|
72
|
+
await this.connection.shell(`inputd-cli swipe ${Math.round(startX)} ${Math.round(startY)} ` +
|
|
73
|
+
`${Math.round(endX)} ${Math.round(endY)} --interval ${Math.round(durationMs)}`);
|
|
74
|
+
await settle();
|
|
75
|
+
}
|
|
76
|
+
async inputText(text) {
|
|
77
|
+
if (text.includes('\n') || text.includes('\r')) {
|
|
78
|
+
throw new Error('Vega keyboard text must not contain newlines');
|
|
79
|
+
}
|
|
80
|
+
await this.ensureInputAvailable();
|
|
81
|
+
await this.connection.shell(`inputd-cli send_text ${shellQuote(text)}`);
|
|
82
|
+
await settle();
|
|
83
|
+
}
|
|
84
|
+
async eraseText(charactersToErase) {
|
|
85
|
+
await this.ensureInputAvailable();
|
|
86
|
+
for (let i = 0; i < charactersToErase; i++) {
|
|
87
|
+
await this.connection.shell('inputd-cli button_press KEY_BACKSPACE');
|
|
88
|
+
}
|
|
89
|
+
await settle();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.VegaInput = VegaInput;
|
|
93
|
+
function settle() {
|
|
94
|
+
// Give the focus engine time to keep up (CI's software renderer is slow).
|
|
95
|
+
return new Promise((r) => setTimeout(r, SETTLE_MS));
|
|
96
|
+
}
|
|
97
|
+
/** Single-quote a string for a POSIX device shell, escaping embedded quotes. */
|
|
98
|
+
function shellQuote(value) {
|
|
99
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
100
|
+
}
|