@houwert/conductor 0.29.3 → 0.30.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/README.md +1 -1
- package/dist/commands/assert-not-visible.js +4 -1
- package/dist/commands/assert-visible.js +5 -2
- package/dist/commands/back.js +2 -1
- package/dist/commands/capture-ui.js +7 -3
- package/dist/commands/clipboard.js +9 -0
- package/dist/commands/copy-text-from.js +2 -1
- package/dist/commands/crashes.js +5 -0
- package/dist/commands/delete-device.js +5 -0
- package/dist/commands/download-app.js +4 -0
- package/dist/commands/erase-text.js +4 -1
- package/dist/commands/focused.js +5 -2
- package/dist/commands/foreground-app.js +4 -1
- package/dist/commands/gestures.js +7 -0
- package/dist/commands/hide-keyboard.js +5 -0
- package/dist/commands/inspect.js +10 -3
- package/dist/commands/install-app.js +5 -0
- package/dist/commands/launch-app.js +3 -2
- package/dist/commands/list-apps.js +5 -0
- package/dist/commands/list-devices.js +12 -0
- package/dist/commands/memory.js +5 -0
- package/dist/commands/press-key.js +15 -0
- package/dist/commands/profile.js +4 -0
- package/dist/commands/screenshot.js +5 -2
- package/dist/commands/scroll-until-visible.js +5 -2
- package/dist/commands/scroll.js +4 -1
- package/dist/commands/start-device.js +27 -3
- package/dist/commands/stop-app.js +2 -1
- package/dist/commands/stop-device.js +12 -1
- package/dist/commands/swipe.js +4 -1
- package/dist/commands/tap.js +3 -2
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/daemon/server.js +14 -4
- package/dist/drivers/bootstrap.js +10 -0
- package/dist/drivers/flow-runner.js +16 -3
- package/dist/drivers/roku/app-ui-parser.js +122 -0
- package/dist/drivers/roku/discovery.js +136 -0
- package/dist/drivers/roku/ecp-client.js +396 -0
- package/dist/drivers/roku/key-mapping.js +67 -0
- package/dist/drivers/roku.js +237 -0
- package/dist/drivers/vega/page-source-parser.js +5 -118
- package/dist/drivers/xml.js +128 -0
- package/dist/enum-options.js +3 -1
- package/dist/index.js +1 -1
- package/dist/runner.js +32 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +4 -3
- package/skills/conductor-device-setup/SKILL.md +35 -2
- package/skills/conductor-profiler/SKILL.md +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RokuDriver = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Driver client for Roku devices, reached over the network via the External
|
|
6
|
+
* Control Protocol (ECP — an HTTP REST API on device port 8060). Roku has no
|
|
7
|
+
* on-device driver process and no local control port; every operation is a
|
|
8
|
+
* stateless ECP call:
|
|
9
|
+
* - view hierarchy from `/query/app-ui` (SceneGraph XML, dev-mode channels only),
|
|
10
|
+
* re-emitted as uiautomator XML so the Android element resolver handles it,
|
|
11
|
+
* - input via `/keypress/<key>` (D-pad focus navigation — there is no touch),
|
|
12
|
+
* - text via character-by-character `LIT_` keypresses,
|
|
13
|
+
* - screenshots via the dev web server's `/plugin_inspect` (digest auth with
|
|
14
|
+
* `CONDUCTOR_ROKU_PASSWORD`),
|
|
15
|
+
* - app lifecycle via `/launch/<channelId>`.
|
|
16
|
+
*
|
|
17
|
+
* Physical hardware only — Roku has no emulator. The device must be in developer
|
|
18
|
+
* mode with "Control by mobile apps" network access set to Permissive.
|
|
19
|
+
*/
|
|
20
|
+
const ecp_client_js_1 = require("./roku/ecp-client.js");
|
|
21
|
+
const discovery_js_1 = require("./roku/discovery.js");
|
|
22
|
+
const key_mapping_js_1 = require("./roku/key-mapping.js");
|
|
23
|
+
const app_ui_parser_js_1 = require("./roku/app-ui-parser.js");
|
|
24
|
+
const utils_js_1 = require("../utils.js");
|
|
25
|
+
const verbose_js_1 = require("../verbose.js");
|
|
26
|
+
const LAUNCH_TIMEOUT_MS = 10000;
|
|
27
|
+
const EXIT_TIMEOUT_MS = 5000;
|
|
28
|
+
const LONG_PRESS_MS = 1000;
|
|
29
|
+
const SWIPE_KEY_PRESSES = 5;
|
|
30
|
+
const UNSUPPORTED = (op) => new Error(`${op} is not supported on roku`);
|
|
31
|
+
class RokuDriver {
|
|
32
|
+
/** `host` is the bare device IP/hostname, not the `roku:` id. */
|
|
33
|
+
constructor(host) {
|
|
34
|
+
this.host = host;
|
|
35
|
+
this.platform = 'roku';
|
|
36
|
+
this.deviceDetails = null;
|
|
37
|
+
this.ecp = new ecp_client_js_1.RokuEcpClient(host, { password: (0, discovery_js_1.rokuPassword)() });
|
|
38
|
+
}
|
|
39
|
+
async isAlive() {
|
|
40
|
+
return this.ecp.isReachable();
|
|
41
|
+
}
|
|
42
|
+
async deviceInfo() {
|
|
43
|
+
const info = await this.resolveDetails();
|
|
44
|
+
return { widthPixels: info.widthPixels, heightPixels: info.heightPixels };
|
|
45
|
+
}
|
|
46
|
+
/** Cached: the hierarchy path reads this, so an uncached miss is an extra round trip per command. */
|
|
47
|
+
async resolveDetails() {
|
|
48
|
+
if (this.deviceDetails)
|
|
49
|
+
return this.deviceDetails;
|
|
50
|
+
const info = await this.ecp.getDeviceInfo();
|
|
51
|
+
if (!info)
|
|
52
|
+
throw new Error(`Failed to read device info from the Roku device at ${this.host}`);
|
|
53
|
+
this.deviceDetails = info;
|
|
54
|
+
return info;
|
|
55
|
+
}
|
|
56
|
+
// ── Input ──────────────────────────────────────────────────────────────────
|
|
57
|
+
/** Roku is D-pad based: there is no coordinate tap, so Select activates whatever holds focus. */
|
|
58
|
+
async tap(_x, _y, duration) {
|
|
59
|
+
if (duration && duration >= 0.5) {
|
|
60
|
+
await this.longPress(_x, _y);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
(0, verbose_js_1.log)('roku: tap() maps to a Select keypress (Roku is D-pad based)');
|
|
64
|
+
await this.ecp.sendKeypress('Select');
|
|
65
|
+
}
|
|
66
|
+
async longPress(_x, _y) {
|
|
67
|
+
// Some Roku channels respond to a held Select key.
|
|
68
|
+
await this.ecp.sendKeyDown('Select');
|
|
69
|
+
await (0, utils_js_1.sleep)(LONG_PRESS_MS);
|
|
70
|
+
await this.ecp.sendKeyUp('Select');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A swipe on Roku is repeated D-pad presses in the direction the *content*
|
|
74
|
+
* moves, which is the opposite of the direction the finger travels.
|
|
75
|
+
*/
|
|
76
|
+
async swipe(startX, startY, endX, endY, _durationMs) {
|
|
77
|
+
const dx = endX - startX;
|
|
78
|
+
const dy = endY - startY;
|
|
79
|
+
const direction = Math.abs(dy) > Math.abs(dx) ? (dy < 0 ? 'up' : 'down') : dx < 0 ? 'left' : 'right';
|
|
80
|
+
await this.swipeDirection(direction);
|
|
81
|
+
}
|
|
82
|
+
async swipeDirection(direction) {
|
|
83
|
+
const key = (0, key_mapping_js_1.rokuSwipeKey)(direction);
|
|
84
|
+
for (let i = 0; i < SWIPE_KEY_PRESSES; i++)
|
|
85
|
+
await this.ecp.sendKeypress(key);
|
|
86
|
+
}
|
|
87
|
+
/** Sends a key by its conductor name (e.g. `Remote Dpad Up`). Throws if unmapped. */
|
|
88
|
+
async pressKeyNamed(name) {
|
|
89
|
+
const key = (0, key_mapping_js_1.rokuEcpKey)(name);
|
|
90
|
+
if (!key)
|
|
91
|
+
throw new Error(`Key "${name}" is not supported on roku`);
|
|
92
|
+
await this.ecp.sendKeypress(key);
|
|
93
|
+
}
|
|
94
|
+
async back() {
|
|
95
|
+
await this.ecp.sendKeypress('Back');
|
|
96
|
+
}
|
|
97
|
+
async inputText(text) {
|
|
98
|
+
await this.ecp.sendText(text);
|
|
99
|
+
}
|
|
100
|
+
async eraseAllText(charactersToErase = 50) {
|
|
101
|
+
for (let i = 0; i < charactersToErase; i++)
|
|
102
|
+
await this.ecp.sendKeypress('Backspace');
|
|
103
|
+
}
|
|
104
|
+
// ── Inspection ─────────────────────────────────────────────────────────────
|
|
105
|
+
/** Returns uiautomator-style XML (consumed by the Android element resolver). */
|
|
106
|
+
async viewHierarchy() {
|
|
107
|
+
const xml = await this.ecp.getAppUIRaw();
|
|
108
|
+
if (xml === null) {
|
|
109
|
+
throw new Error(`Failed to read the view hierarchy from the Roku device at ${this.host}. ` +
|
|
110
|
+
`Check ECP network access (Settings > System > Advanced system settings > ` +
|
|
111
|
+
`Control by mobile apps > Network access > Permissive), and note that ` +
|
|
112
|
+
`/query/app-ui only reports sideloaded dev-mode channels.`);
|
|
113
|
+
}
|
|
114
|
+
const info = await this.resolveDetails().catch(() => null);
|
|
115
|
+
return (0, app_ui_parser_js_1.parseRokuAppUI)(xml, info?.widthPixels, info?.heightPixels);
|
|
116
|
+
}
|
|
117
|
+
async screenshot(_opts = {}) {
|
|
118
|
+
return this.ecp.takeScreenshot();
|
|
119
|
+
}
|
|
120
|
+
// ── App lifecycle ──────────────────────────────────────────────────────────
|
|
121
|
+
/**
|
|
122
|
+
* Cold launch. ECP has no terminate endpoint and `launch` resumes an already
|
|
123
|
+
* running channel with its state intact, so exit to the home screen first to
|
|
124
|
+
* force a restart from the channel's initial state.
|
|
125
|
+
*/
|
|
126
|
+
async launchApp(appId, args) {
|
|
127
|
+
if (await this.ecp.isActiveApp(appId)) {
|
|
128
|
+
await this.ecp.sendKeypress('Home');
|
|
129
|
+
const exitDeadline = Date.now() + EXIT_TIMEOUT_MS;
|
|
130
|
+
while (Date.now() < exitDeadline && (await this.ecp.isActiveApp(appId))) {
|
|
131
|
+
await (0, utils_js_1.sleep)(200);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await this.ecp.launchChannel(appId, args ?? {});
|
|
135
|
+
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
136
|
+
let active = false;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
if (await this.ecp.isActiveApp(appId)) {
|
|
139
|
+
active = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
await (0, utils_js_1.sleep)(200);
|
|
143
|
+
}
|
|
144
|
+
if (!active) {
|
|
145
|
+
// Returning here would leave the flow asserting against whatever screen the
|
|
146
|
+
// device happens to show, failing later with an unrelated message.
|
|
147
|
+
throw new Error(`Roku channel ${appId} did not become the active app within ${LAUNCH_TIMEOUT_MS}ms. ` +
|
|
148
|
+
`Check the channel id (a sideloaded channel is \`dev\`) and that it is installed.`);
|
|
149
|
+
}
|
|
150
|
+
// Wait for the channel UI to render — a SceneGraph screen with child nodes.
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
const xml = await this.ecp.getAppUIRaw();
|
|
153
|
+
if (xml && /<screen\b[^>]*>\s*</.test(xml))
|
|
154
|
+
return;
|
|
155
|
+
await (0, utils_js_1.sleep)(500);
|
|
156
|
+
}
|
|
157
|
+
(0, verbose_js_1.log)(`roku: channel ${appId} launched but its UI may not be fully rendered`);
|
|
158
|
+
}
|
|
159
|
+
/** Roku has no "stop app" ECP endpoint — pressing Home exits the channel. */
|
|
160
|
+
async stopApp(_appId) {
|
|
161
|
+
await this.ecp.sendKeypress('Home');
|
|
162
|
+
}
|
|
163
|
+
async terminateApp(appId) {
|
|
164
|
+
await this.stopApp(appId);
|
|
165
|
+
}
|
|
166
|
+
async getForegroundApp() {
|
|
167
|
+
const app = await this.ecp.getActiveApp();
|
|
168
|
+
if (!app?.id)
|
|
169
|
+
throw new Error('Could not determine the active Roku channel');
|
|
170
|
+
return app.id;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Roku deep links are a `contentId` launch parameter on a channel, so a link
|
|
174
|
+
* needs a channel to open it. Callers pass only a URL, so this targets the
|
|
175
|
+
* channel already in the foreground — the one the flow is testing.
|
|
176
|
+
*/
|
|
177
|
+
async openLink(url, appId) {
|
|
178
|
+
const channel = appId ?? (await this.ecp.getActiveApp())?.id;
|
|
179
|
+
if (!channel) {
|
|
180
|
+
throw new Error('open-link on roku needs a channel to open the link in, and none is active. ' +
|
|
181
|
+
'Launch the channel first (`conductor launch-app <channelId>`), or pass the ' +
|
|
182
|
+
'link as a launch argument.');
|
|
183
|
+
}
|
|
184
|
+
await this.ecp.launchChannel(channel, { contentId: url });
|
|
185
|
+
}
|
|
186
|
+
// ── Unsupported on roku (gated with clear errors) ───────────────────────────
|
|
187
|
+
async installApp(_path) {
|
|
188
|
+
throw new Error('install-app is not supported on roku — sideload the channel through the ' +
|
|
189
|
+
"device's developer web server (http://<device-ip>).");
|
|
190
|
+
}
|
|
191
|
+
async uninstallApp(_appId) {
|
|
192
|
+
throw UNSUPPORTED('uninstall-app');
|
|
193
|
+
}
|
|
194
|
+
async listInstalledApps() {
|
|
195
|
+
throw UNSUPPORTED('list-apps');
|
|
196
|
+
}
|
|
197
|
+
async clearAppState(_appId) {
|
|
198
|
+
throw new Error('clear-state is not supported on roku — re-sideload the channel to reset its state.');
|
|
199
|
+
}
|
|
200
|
+
async clearKeychain() {
|
|
201
|
+
// Not applicable on Roku — no-op.
|
|
202
|
+
}
|
|
203
|
+
async setLocation(_latitude, _longitude) {
|
|
204
|
+
throw UNSUPPORTED('set-location');
|
|
205
|
+
}
|
|
206
|
+
async setOrientation(_orientation) {
|
|
207
|
+
// No-op: TVs don't rotate.
|
|
208
|
+
}
|
|
209
|
+
async setPermissions(_appId, _permissions) {
|
|
210
|
+
// No runtime-permission grant primitive on Roku — no-op.
|
|
211
|
+
}
|
|
212
|
+
async addMedia(_filePath) {
|
|
213
|
+
throw UNSUPPORTED('add-media');
|
|
214
|
+
}
|
|
215
|
+
async setAirplaneMode(_enabled) {
|
|
216
|
+
throw UNSUPPORTED('airplane mode');
|
|
217
|
+
}
|
|
218
|
+
async getAirplaneMode() {
|
|
219
|
+
throw UNSUPPORTED('airplane mode');
|
|
220
|
+
}
|
|
221
|
+
async gesturePath(_paths) {
|
|
222
|
+
throw UNSUPPORTED('multi-finger gestures');
|
|
223
|
+
}
|
|
224
|
+
async startRecording(_outputPath) {
|
|
225
|
+
throw UNSUPPORTED('screen recording');
|
|
226
|
+
}
|
|
227
|
+
async stopRecording() {
|
|
228
|
+
throw UNSUPPORTED('screen recording');
|
|
229
|
+
}
|
|
230
|
+
async clipboardRead() {
|
|
231
|
+
throw UNSUPPORTED('clipboard');
|
|
232
|
+
}
|
|
233
|
+
async clipboardWrite(_text) {
|
|
234
|
+
throw UNSUPPORTED('clipboard');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.RokuDriver = RokuDriver;
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.parseVegaPageSource = parseVegaPageSource;
|
|
19
|
+
const xml_js_1 = require("../xml.js");
|
|
19
20
|
const LAUNCHER_APP = 'com.amazon.keplerlauncherapp';
|
|
20
21
|
/** Parse Vega toolkit XML and re-emit it as uiautomator `<node>` XML. */
|
|
21
22
|
function parseVegaPageSource(xml) {
|
|
22
|
-
const root = parseXml(xml);
|
|
23
|
+
const root = (0, xml_js_1.parseXml)(xml);
|
|
23
24
|
if (!root)
|
|
24
25
|
return '<?xml version="1.0" encoding="UTF-8"?>\n<hierarchy />';
|
|
25
26
|
const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<hierarchy>'];
|
|
@@ -53,13 +54,13 @@ function emitNode(node, lines, depth) {
|
|
|
53
54
|
const attrs = [];
|
|
54
55
|
const role = node.attrs['role'];
|
|
55
56
|
if (role)
|
|
56
|
-
attrs.push(`class="${xmlEscape(role)}"`);
|
|
57
|
+
attrs.push(`class="${(0, xml_js_1.xmlEscape)(role)}"`);
|
|
57
58
|
const testId = node.attrs['test_id'];
|
|
58
59
|
if (testId)
|
|
59
|
-
attrs.push(`resource-id="${xmlEscape(testId)}"`);
|
|
60
|
+
attrs.push(`resource-id="${(0, xml_js_1.xmlEscape)(testId)}"`);
|
|
60
61
|
const label = labelOf(node);
|
|
61
62
|
if (label)
|
|
62
|
-
attrs.push(`text="${xmlEscape(label)}"`);
|
|
63
|
+
attrs.push(`text="${(0, xml_js_1.xmlEscape)(label)}"`);
|
|
63
64
|
attrs.push(`bounds="${boundsOf(node)}"`);
|
|
64
65
|
const interactive = isInteractive(node);
|
|
65
66
|
attrs.push(`clickable="${interactive ? 'true' : 'false'}"`);
|
|
@@ -113,117 +114,3 @@ function boolAttr(node, name) {
|
|
|
113
114
|
function intAttr(node, name) {
|
|
114
115
|
return parseInt(node.attrs[name] ?? '', 10) || 0;
|
|
115
116
|
}
|
|
116
|
-
function xmlEscape(s) {
|
|
117
|
-
return s
|
|
118
|
-
.replace(/&/g, '&')
|
|
119
|
-
.replace(/</g, '<')
|
|
120
|
-
.replace(/>/g, '>')
|
|
121
|
-
.replace(/"/g, '"');
|
|
122
|
-
}
|
|
123
|
-
// ── Minimal XML parser ────────────────────────────────────────────────────────
|
|
124
|
-
// Handles elements, attributes, text nodes, self-closing tags, and skips the XML
|
|
125
|
-
// declaration / comments. Sufficient for the well-formed toolkit output.
|
|
126
|
-
function parseXml(xml) {
|
|
127
|
-
let i = 0;
|
|
128
|
-
const n = xml.length;
|
|
129
|
-
function skipWhitespace() {
|
|
130
|
-
while (i < n && /\s/.test(xml[i]))
|
|
131
|
-
i++;
|
|
132
|
-
}
|
|
133
|
-
function parseNode() {
|
|
134
|
-
// Skip declarations, comments, and processing instructions before the tag.
|
|
135
|
-
while (i < n) {
|
|
136
|
-
skipWhitespace();
|
|
137
|
-
if (xml.startsWith('<?', i)) {
|
|
138
|
-
i = xml.indexOf('?>', i);
|
|
139
|
-
i = i === -1 ? n : i + 2;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (xml.startsWith('<!--', i)) {
|
|
143
|
-
i = xml.indexOf('-->', i);
|
|
144
|
-
i = i === -1 ? n : i + 3;
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (xml.startsWith('<!', i)) {
|
|
148
|
-
i = xml.indexOf('>', i);
|
|
149
|
-
i = i === -1 ? n : i + 1;
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
if (i >= n || xml[i] !== '<')
|
|
155
|
-
return null;
|
|
156
|
-
i++; // consume '<'
|
|
157
|
-
// Read tag name
|
|
158
|
-
const nameStart = i;
|
|
159
|
-
while (i < n && !/[\s/>]/.test(xml[i]))
|
|
160
|
-
i++;
|
|
161
|
-
const tag = xml.slice(nameStart, i);
|
|
162
|
-
const attrs = {};
|
|
163
|
-
// Read attributes
|
|
164
|
-
while (i < n) {
|
|
165
|
-
skipWhitespace();
|
|
166
|
-
if (xml[i] === '/' || xml[i] === '>')
|
|
167
|
-
break;
|
|
168
|
-
const attrNameStart = i;
|
|
169
|
-
while (i < n && !/[\s=/>]/.test(xml[i]))
|
|
170
|
-
i++;
|
|
171
|
-
const attrName = xml.slice(attrNameStart, i);
|
|
172
|
-
skipWhitespace();
|
|
173
|
-
let attrValue = '';
|
|
174
|
-
if (xml[i] === '=') {
|
|
175
|
-
i++; // consume '='
|
|
176
|
-
skipWhitespace();
|
|
177
|
-
const quote = xml[i];
|
|
178
|
-
if (quote === '"' || quote === "'") {
|
|
179
|
-
i++;
|
|
180
|
-
const valStart = i;
|
|
181
|
-
while (i < n && xml[i] !== quote)
|
|
182
|
-
i++;
|
|
183
|
-
attrValue = xmlUnescape(xml.slice(valStart, i));
|
|
184
|
-
i++; // consume closing quote
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (attrName)
|
|
188
|
-
attrs[attrName] = attrValue;
|
|
189
|
-
}
|
|
190
|
-
const node = { tag, attrs, children: [], text: '' };
|
|
191
|
-
if (xml[i] === '/') {
|
|
192
|
-
// self-closing
|
|
193
|
-
i = xml.indexOf('>', i);
|
|
194
|
-
i = i === -1 ? n : i + 1;
|
|
195
|
-
return node;
|
|
196
|
-
}
|
|
197
|
-
i++; // consume '>'
|
|
198
|
-
// Read children / text until closing tag
|
|
199
|
-
while (i < n) {
|
|
200
|
-
if (xml.startsWith('</', i)) {
|
|
201
|
-
i = xml.indexOf('>', i);
|
|
202
|
-
i = i === -1 ? n : i + 1;
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
if (xml[i] === '<') {
|
|
206
|
-
const child = parseNode();
|
|
207
|
-
if (child)
|
|
208
|
-
node.children.push(child);
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
const textStart = i;
|
|
212
|
-
while (i < n && xml[i] !== '<')
|
|
213
|
-
i++;
|
|
214
|
-
node.text += xmlUnescape(xml.slice(textStart, i));
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
return node;
|
|
218
|
-
}
|
|
219
|
-
return parseNode();
|
|
220
|
-
}
|
|
221
|
-
function xmlUnescape(s) {
|
|
222
|
-
return s
|
|
223
|
-
.replace(/</g, '<')
|
|
224
|
-
.replace(/>/g, '>')
|
|
225
|
-
.replace(/"/g, '"')
|
|
226
|
-
.replace(/'/g, "'")
|
|
227
|
-
.replace(/'/g, "'")
|
|
228
|
-
.replace(/&/g, '&');
|
|
229
|
-
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal XML parser shared by the driver hierarchy adapters (Vega toolkit output,
|
|
4
|
+
* Roku ECP `app-ui`). Handles elements, attributes, text nodes, self-closing tags,
|
|
5
|
+
* and skips declarations/comments — sufficient for well-formed device output, and
|
|
6
|
+
* cheaper than pulling in a dependency for two callers.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.parseXml = parseXml;
|
|
10
|
+
exports.childElement = childElement;
|
|
11
|
+
exports.xmlEscape = xmlEscape;
|
|
12
|
+
exports.xmlUnescape = xmlUnescape;
|
|
13
|
+
/** Parse a document and return its root element, or null if there is none. */
|
|
14
|
+
function parseXml(xml) {
|
|
15
|
+
let i = 0;
|
|
16
|
+
const n = xml.length;
|
|
17
|
+
function skipWhitespace() {
|
|
18
|
+
while (i < n && /\s/.test(xml[i]))
|
|
19
|
+
i++;
|
|
20
|
+
}
|
|
21
|
+
function parseNode() {
|
|
22
|
+
// Skip declarations, comments, and processing instructions before the tag.
|
|
23
|
+
while (i < n) {
|
|
24
|
+
skipWhitespace();
|
|
25
|
+
if (xml.startsWith('<?', i)) {
|
|
26
|
+
i = xml.indexOf('?>', i);
|
|
27
|
+
i = i === -1 ? n : i + 2;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (xml.startsWith('<!--', i)) {
|
|
31
|
+
i = xml.indexOf('-->', i);
|
|
32
|
+
i = i === -1 ? n : i + 3;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (xml.startsWith('<!', i)) {
|
|
36
|
+
i = xml.indexOf('>', i);
|
|
37
|
+
i = i === -1 ? n : i + 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (i >= n || xml[i] !== '<')
|
|
43
|
+
return null;
|
|
44
|
+
i++; // consume '<'
|
|
45
|
+
// Read tag name
|
|
46
|
+
const nameStart = i;
|
|
47
|
+
while (i < n && !/[\s/>]/.test(xml[i]))
|
|
48
|
+
i++;
|
|
49
|
+
const tag = xml.slice(nameStart, i);
|
|
50
|
+
const attrs = {};
|
|
51
|
+
// Read attributes
|
|
52
|
+
while (i < n) {
|
|
53
|
+
skipWhitespace();
|
|
54
|
+
if (xml[i] === '/' || xml[i] === '>')
|
|
55
|
+
break;
|
|
56
|
+
const attrNameStart = i;
|
|
57
|
+
while (i < n && !/[\s=/>]/.test(xml[i]))
|
|
58
|
+
i++;
|
|
59
|
+
const attrName = xml.slice(attrNameStart, i);
|
|
60
|
+
skipWhitespace();
|
|
61
|
+
let attrValue = '';
|
|
62
|
+
if (xml[i] === '=') {
|
|
63
|
+
i++; // consume '='
|
|
64
|
+
skipWhitespace();
|
|
65
|
+
const quote = xml[i];
|
|
66
|
+
if (quote === '"' || quote === "'") {
|
|
67
|
+
i++;
|
|
68
|
+
const valStart = i;
|
|
69
|
+
while (i < n && xml[i] !== quote)
|
|
70
|
+
i++;
|
|
71
|
+
attrValue = xmlUnescape(xml.slice(valStart, i));
|
|
72
|
+
i++; // consume closing quote
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (attrName)
|
|
76
|
+
attrs[attrName] = attrValue;
|
|
77
|
+
}
|
|
78
|
+
const node = { tag, attrs, children: [], text: '' };
|
|
79
|
+
if (xml[i] === '/') {
|
|
80
|
+
// self-closing
|
|
81
|
+
i = xml.indexOf('>', i);
|
|
82
|
+
i = i === -1 ? n : i + 1;
|
|
83
|
+
return node;
|
|
84
|
+
}
|
|
85
|
+
i++; // consume '>'
|
|
86
|
+
// Read children / text until closing tag
|
|
87
|
+
while (i < n) {
|
|
88
|
+
if (xml.startsWith('</', i)) {
|
|
89
|
+
i = xml.indexOf('>', i);
|
|
90
|
+
i = i === -1 ? n : i + 1;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (xml[i] === '<') {
|
|
94
|
+
const child = parseNode();
|
|
95
|
+
if (child)
|
|
96
|
+
node.children.push(child);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const textStart = i;
|
|
100
|
+
while (i < n && xml[i] !== '<')
|
|
101
|
+
i++;
|
|
102
|
+
node.text += xmlUnescape(xml.slice(textStart, i));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return node;
|
|
106
|
+
}
|
|
107
|
+
return parseNode();
|
|
108
|
+
}
|
|
109
|
+
/** First direct child element with the given tag name. */
|
|
110
|
+
function childElement(parent, tag) {
|
|
111
|
+
return parent.children.find((c) => c.tag === tag);
|
|
112
|
+
}
|
|
113
|
+
function xmlEscape(s) {
|
|
114
|
+
return s
|
|
115
|
+
.replace(/&/g, '&')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
118
|
+
.replace(/"/g, '"');
|
|
119
|
+
}
|
|
120
|
+
function xmlUnescape(s) {
|
|
121
|
+
return s
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, "'")
|
|
126
|
+
.replace(/'/g, "'")
|
|
127
|
+
.replace(/&/g, '&');
|
|
128
|
+
}
|
package/dist/enum-options.js
CHANGED
|
@@ -24,7 +24,7 @@ exports.ENUM_PARAMS = [
|
|
|
24
24
|
param: '<key>',
|
|
25
25
|
description: 'Key, hardware button, or remote button to press',
|
|
26
26
|
values: press_key_js_1.VALID_KEYS.map((value) => ({ value })),
|
|
27
|
-
note: 'Matched case-insensitively. Availability varies by platform: "Remote …" / "TV …" keys target tvOS, Android TV,
|
|
27
|
+
note: 'Matched case-insensitively. Availability varies by platform: "Remote …" / "TV …" keys target tvOS, Android TV, vega (Amazon Fire TV), and roku; hardware buttons (Home, Lock, Power, Volume…) target iOS/Android.',
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
command: 'scroll',
|
|
@@ -62,6 +62,7 @@ exports.ENUM_PARAMS = [
|
|
|
62
62
|
{ value: 'tvos' },
|
|
63
63
|
{ value: 'web' },
|
|
64
64
|
{ value: 'vega' },
|
|
65
|
+
{ value: 'roku' },
|
|
65
66
|
],
|
|
66
67
|
},
|
|
67
68
|
{
|
|
@@ -110,6 +111,7 @@ exports.ENUM_PARAMS = [
|
|
|
110
111
|
{ value: 'tvos' },
|
|
111
112
|
{ value: 'web' },
|
|
112
113
|
{ value: 'vega' },
|
|
114
|
+
{ value: 'roku' },
|
|
113
115
|
],
|
|
114
116
|
},
|
|
115
117
|
];
|
package/dist/index.js
CHANGED
|
@@ -176,7 +176,7 @@ const COMMAND_HELP = {
|
|
|
176
176
|
const OPTIONS_HELP = `Options:
|
|
177
177
|
--device <id> Target device ID (also keys the session and daemon)
|
|
178
178
|
--device-name <n> Target a booted device by name (resolved to ID from booted devices)
|
|
179
|
-
--platform <p> Filter to devices of this platform (ios, android, tvos, web, vega)
|
|
179
|
+
--platform <p> Filter to devices of this platform (ios, android, tvos, web, vega, roku)
|
|
180
180
|
--cdp-url <url> Attach the web driver to an existing browser over CDP (e.g. an
|
|
181
181
|
Electron app started with --remote-debugging-port). Remembered per session.
|
|
182
182
|
--cdp-target <id> Pick which CDP page target to control (see \`conductor web-targets\`)
|
package/dist/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectFirstDevice = detectFirstDevice;
|
|
4
|
+
exports.rokuHost = rokuHost;
|
|
4
5
|
exports.getDriver = getDriver;
|
|
5
6
|
exports.prewarmDriver = prewarmDriver;
|
|
6
7
|
exports.inputServerInfo = inputServerInfo;
|
|
@@ -18,6 +19,8 @@ const android_js_1 = require("./drivers/android.js");
|
|
|
18
19
|
const web_js_1 = require("./drivers/web.js");
|
|
19
20
|
const vega_js_1 = require("./drivers/vega.js");
|
|
20
21
|
const cli_js_1 = require("./drivers/vega/cli.js");
|
|
22
|
+
const roku_js_1 = require("./drivers/roku.js");
|
|
23
|
+
const discovery_js_1 = require("./drivers/roku/discovery.js");
|
|
21
24
|
const bootstrap_js_1 = require("./drivers/bootstrap.js");
|
|
22
25
|
const client_js_1 = require("./daemon/client.js");
|
|
23
26
|
/**
|
|
@@ -93,6 +96,14 @@ async function detectFirstDevice() {
|
|
|
93
96
|
catch {
|
|
94
97
|
/* vega CLI not installed */
|
|
95
98
|
}
|
|
99
|
+
// Roku: pinned via CONDUCTOR_ROKU_HOST (or an opt-in SSDP scan). Best-effort —
|
|
100
|
+
// neither is configured unless the user set up a Roku device.
|
|
101
|
+
const roku = (await (0, discovery_js_1.discoverRokuDevices)().catch(() => []))[0];
|
|
102
|
+
if (roku) {
|
|
103
|
+
(0, verbose_js_1.log)(`detectFirstDevice: found Roku device "${roku.host}"`);
|
|
104
|
+
_cachedDeviceId = `roku:${roku.host}`;
|
|
105
|
+
return _cachedDeviceId;
|
|
106
|
+
}
|
|
96
107
|
_cachedDeviceId = null;
|
|
97
108
|
return undefined;
|
|
98
109
|
}
|
|
@@ -100,6 +111,10 @@ async function detectFirstDevice() {
|
|
|
100
111
|
function vegaSerial(deviceId) {
|
|
101
112
|
return deviceId.startsWith('vega:') ? deviceId.slice('vega:'.length) : deviceId;
|
|
102
113
|
}
|
|
114
|
+
/** Strip the `roku:` prefix to recover the bare device host. */
|
|
115
|
+
function rokuHost(deviceId) {
|
|
116
|
+
return deviceId.startsWith('roku:') ? deviceId.slice('roku:'.length) : deviceId;
|
|
117
|
+
}
|
|
103
118
|
/** Per-session driver cache (process lifetime). */
|
|
104
119
|
const _driverCache = new Map();
|
|
105
120
|
/**
|
|
@@ -199,6 +214,23 @@ async function getDriver(sessionName = 'default') {
|
|
|
199
214
|
}
|
|
200
215
|
driver = vegaDriver;
|
|
201
216
|
}
|
|
217
|
+
else if (platform === 'roku') {
|
|
218
|
+
// Roku has no driver process and no daemon: control is ECP over the network,
|
|
219
|
+
// and the device exposes no log stream to collect.
|
|
220
|
+
const host = rokuHost(deviceId);
|
|
221
|
+
if (!host || host === 'roku') {
|
|
222
|
+
throw new Error('No Roku device specified. Set CONDUCTOR_ROKU_HOST=<device-ip>, or pass ' +
|
|
223
|
+
'--device roku:<device-ip>.');
|
|
224
|
+
}
|
|
225
|
+
const rokuDriver = new roku_js_1.RokuDriver(host);
|
|
226
|
+
if (!(await rokuDriver.isAlive())) {
|
|
227
|
+
throw new Error(`Cannot reach the Roku device at ${host} on ECP port 8060.\n` +
|
|
228
|
+
`Check that it is on this network and in developer mode, and that ` +
|
|
229
|
+
`Settings > System > Advanced system settings > Control by mobile apps > ` +
|
|
230
|
+
`Network access is set to "Permissive".`);
|
|
231
|
+
}
|
|
232
|
+
driver = rokuDriver;
|
|
233
|
+
}
|
|
202
234
|
else {
|
|
203
235
|
// Ensure the daemon is running — it handles APK install and driver startup.
|
|
204
236
|
await (0, client_js_1.startDaemon)(deviceId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: conductor-device-interact
|
|
3
|
-
description: Drive a running iOS simulator, Android emulator, tvOS simulator, Vega (Amazon Fire TV) virtual device, or Playwright web app with the conductor CLI. Use when launching apps, tapping UI elements (by selector or raw coordinate), typing text, scrolling/swiping, performing gestures, pressing hardware/keyboard/remote keys, opening URLs or deep links, navigating back, granting/denying app permissions, adding media to the gallery, setting GPS location or a travel route, toggling airplane mode, recording a screen video, or verifying an app change in the real running app.
|
|
3
|
+
description: Drive a running iOS simulator, Android emulator, tvOS simulator, Vega (Amazon Fire TV) virtual device, Roku device, or Playwright web app with the conductor CLI. Use when launching apps, tapping UI elements (by selector or raw coordinate), typing text, scrolling/swiping, performing gestures, pressing hardware/keyboard/remote keys, opening URLs or deep links, navigating back, granting/denying app permissions, adding media to the gallery, setting GPS location or a travel route, toggling airplane mode, recording a screen video, or verifying an app change in the real running app.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Conductor — device interaction
|
|
@@ -41,7 +41,7 @@ conductor assert-visible "Dashboard"
|
|
|
41
41
|
| `conductor copy-text-from <element>` | Print an element's text (and copy to the iOS clipboard) |
|
|
42
42
|
| `conductor input-text <text>` | Type into the focused field |
|
|
43
43
|
| `conductor erase-text [n]` | Erase n characters (default 50) |
|
|
44
|
-
| `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega. `--long-press` / `--duration <seconds>` holds it; `--measure` times the response (see `conductor-profiler`) |
|
|
44
|
+
| `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega / roku. `--long-press` / `--duration <seconds>` holds it; `--measure` times the response (see `conductor-profiler`) |
|
|
45
45
|
| `conductor hide-keyboard` | Dismiss the on-screen keyboard |
|
|
46
46
|
| `conductor back` | Press back |
|
|
47
47
|
| `conductor scroll [--direction down\|up\|left\|right]` | Scroll |
|
|
@@ -125,8 +125,9 @@ relaunch without the flag. (See `conductor-device-setup`.)
|
|
|
125
125
|
|
|
126
126
|
## Tips
|
|
127
127
|
|
|
128
|
-
- `--device <id>` / `--device-name <name>` targets a device; `--platform <ios|android|tvos|web|vega>` scopes by platform.
|
|
128
|
+
- `--device <id>` / `--device-name <name>` targets a device; `--platform <ios|android|tvos|web|vega|roku>` scopes by platform.
|
|
129
129
|
- Vega (Amazon Fire TV) is D-pad driven: navigate with `press-key "Remote Dpad …"`; coordinate `tap-on` also works. `open-link`, `set-location`, gestures, and clipboard are unsupported. See `conductor-device-setup`.
|
|
130
|
+
- Roku is D-pad only — there is no touch. `tap-on <selector>` resolves the element but presses `Select`, which activates whatever currently holds **focus**, so navigate focus onto the target with `press-key "Remote Dpad …"` first and use `tap-on` to confirm. `scroll`/`swipe` become repeated D-pad presses in the direction the content moves. `open-link` needs an app id (it becomes a channel launch parameter). Only sideloaded dev-mode channels are inspectable. See `conductor-device-setup`.
|
|
130
131
|
- Add `--json` for machine-readable output; failed assertions exit non-zero.
|
|
131
132
|
- Run a per-session daemon for many commands (see `conductor-device-setup`).
|
|
132
133
|
- `conductor <command> --help` for exact flags.
|